Improve onboarding

This commit is contained in:
Urtzi Alfaro
2025-12-18 13:26:32 +01:00
parent f76b3f8e6b
commit f10a2b92ea
42 changed files with 2175 additions and 984 deletions

View File

@@ -17,7 +17,6 @@ export const BACKEND_ONBOARDING_STEPS = [
'product-categorization', // Phase 2c: Advanced categorization (optional)
'suppliers-setup', // Phase 2d: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training
@@ -36,7 +35,6 @@ export const FRONTEND_STEP_ORDER = [
'product-categorization', // Phase 2c: Advanced categorization (optional)
'suppliers-setup', // Phase 2d: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training

View File

@@ -86,6 +86,32 @@ export class TenantService {
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/${parentTenantId}/children`);
}
async bulkCreateChildTenants(request: {
parent_tenant_id: string;
child_tenants: Array<{
name: string;
city: string;
zone?: string;
address: string;
postal_code: string;
location_code: string;
latitude?: number;
longitude?: number;
phone?: string;
email?: string;
}>;
auto_configure_distribution?: boolean;
}): Promise<{
parent_tenant_id: string;
created_count: number;
failed_count: number;
created_tenants: TenantResponse[];
failed_tenants: Array<{ name: string; location_code: string; error: string }>;
distribution_configured: boolean;
}> {
return apiClient.post(`${this.baseUrl}/bulk-children`, request);
}
// ===================================================================
// OPERATIONS: Search & Discovery
// Backend: services/tenant/app/api/tenant_operations.py

View File

@@ -209,6 +209,11 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
await register(registrationData);
// CRITICAL: Store subscription_tier in localStorage for onboarding flow
// This is required for conditional step rendering in UnifiedOnboardingWizard
console.log('💾 Storing subscription_tier in localStorage:', selectedPlan);
localStorage.setItem('subscription_tier', selectedPlan);
const successMessage = isPilot
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';

View File

@@ -11,11 +11,11 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
import {
BakeryTypeSelectionStep,
RegisterTenantStep,
ChildTenantsSetupStep,
FileUploadStep,
InventoryReviewStep,
ProductCategorizationStep,
InitialStockEntryStep,
ProductionProcessesStep,
MLTrainingStep,
CompletionStep
} from './steps';
@@ -55,6 +55,26 @@ const OnboardingWizardContent: React.FC = () => {
const { user } = useAuth();
const wizardContext = useWizardContext();
// Get subscription tier from localStorage (set during demo/registration)
const subscriptionTier = localStorage.getItem('subscription_tier') as string;
const isEnterprise = subscriptionTier === 'enterprise';
// Auto-set bakeryType for enterprise users (central baker + retail outlets)
// Do this synchronously on mount AND in useEffect to ensure it's set before VISIBLE_STEPS calculation
React.useEffect(() => {
if (isEnterprise && !wizardContext.state.bakeryType) {
console.log('🏢 Auto-setting bakeryType to "mixed" for enterprise tier');
wizardContext.updateBakeryType('mixed'); // Enterprise is always mixed (production + retail)
}
}, [isEnterprise, wizardContext]);
// CRITICAL: Set bakeryType synchronously for enterprise on initial render
// This ensures the setup step's condition is satisfied immediately
if (isEnterprise && !wizardContext.state.bakeryType) {
console.log('🏢 [SYNC] Auto-setting bakeryType to "mixed" for enterprise tier');
wizardContext.updateBakeryType('mixed');
}
// All possible steps with conditional visibility
// All step IDs match backend ONBOARDING_STEPS exactly
const ALL_STEPS: StepConfig[] = [
@@ -64,15 +84,50 @@ const OnboardingWizardContent: React.FC = () => {
title: t('onboarding:steps.bakery_type.title', 'Tipo de Panadería'),
description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'),
component: BakeryTypeSelectionStep,
isConditional: true,
condition: () => {
// Check localStorage directly to ensure we get the current value
const currentTier = localStorage.getItem('subscription_tier');
return currentTier !== 'enterprise'; // Skip for enterprise users
},
},
// Phase 2: Core Setup
{
id: 'setup',
title: t('onboarding:steps.setup.title', 'Registrar Panadería'),
description: t('onboarding:steps.setup.description', 'Información básica'),
// Dynamic title based on subscription tier
title: (() => {
const currentTier = localStorage.getItem('subscription_tier');
return currentTier === 'enterprise'
? t('onboarding:steps.setup.title_enterprise', 'Registrar Obrador Central')
: t('onboarding:steps.setup.title', 'Registrar Panadería');
})(),
description: (() => {
const currentTier = localStorage.getItem('subscription_tier');
return currentTier === 'enterprise'
? t('onboarding:steps.setup.description_enterprise', 'Información del obrador central')
: t('onboarding:steps.setup.description', 'Información básica');
})(),
component: RegisterTenantStep,
isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null,
condition: (ctx) => {
// Allow setup step if bakeryType is set OR if user is enterprise tier
// (enterprise auto-sets bakeryType to 'mixed' automatically)
const currentTier = localStorage.getItem('subscription_tier');
return ctx.state.bakeryType !== null || currentTier === 'enterprise';
},
},
// Enterprise-specific: Child Tenants Setup
{
id: 'child-tenants-setup',
title: 'Configurar Sucursales',
description: 'Registra las sucursales de tu red empresarial',
component: ChildTenantsSetupStep,
isConditional: true,
condition: () => {
// Check localStorage directly to ensure we get the current value
const currentTier = localStorage.getItem('subscription_tier');
return currentTier === 'enterprise'; // Only show for enterprise users
},
},
// POI Detection removed - now happens automatically in background after tenant registration
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
@@ -116,15 +171,6 @@ const OnboardingWizardContent: React.FC = () => {
condition: (ctx) =>
ctx.state.bakeryType === 'production' || ctx.state.bakeryType === 'mixed',
},
{
id: 'production-processes',
title: t('onboarding:steps.processes.title', 'Procesos'),
description: t('onboarding:steps.processes.description', 'Procesos de terminado'),
component: ProductionProcessesStep,
isConditional: true,
condition: (ctx) =>
ctx.state.bakeryType === 'retail' || ctx.state.bakeryType === 'mixed',
},
// Phase 3: Advanced Features (Optional)
{
id: 'quality-setup',
@@ -171,13 +217,15 @@ const OnboardingWizardContent: React.FC = () => {
console.log('🔄 VISIBLE_STEPS recalculated:', visibleSteps.map(s => s.id));
console.log('📊 Wizard state:', {
bakeryType: wizardContext.state.bakeryType,
subscriptionTier: localStorage.getItem('subscription_tier'),
stockEntryCompleted: wizardContext.state.stockEntryCompleted,
aiAnalysisComplete: wizardContext.state.aiAnalysisComplete,
categorizationCompleted: wizardContext.state.categorizationCompleted,
});
return visibleSteps;
}, [wizardContext.state]);
}, [wizardContext.state, isEnterprise]); // Added isEnterprise to dependencies
const isNewTenant = searchParams.get('new') === 'true';
const [currentStepIndex, setCurrentStepIndex] = useState(0);
@@ -192,7 +240,7 @@ const OnboardingWizardContent: React.FC = () => {
);
const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant } = useTenantActions();
const { setCurrentTenant, loadUserTenants } = useTenantActions();
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
// Auto-complete user_registered step
@@ -335,12 +383,74 @@ const OnboardingWizardContent: React.FC = () => {
if (currentStep.id === 'inventory-setup') {
wizardContext.markStepComplete('inventoryCompleted');
}
if (currentStep.id === 'setup' && data?.tenant) {
setCurrentTenant(data.tenant);
// If tenant info and location are available in data, update the wizard context
if (data.tenantId && data.bakeryLocation) {
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
if (currentStep.id === 'setup') {
console.log('✅ Setup step completed with data:', { hasTenant: !!data?.tenant, hasUser: !!user?.id, data });
if (data?.tenant) {
setCurrentTenant(data.tenant);
// CRITICAL: Reload user tenants to load membership/access information
// This ensures the user has proper permissions when navigating to dashboard
if (user?.id) {
console.log('🔄 [CRITICAL] Reloading user tenants after tenant creation to load access permissions...');
await loadUserTenants();
console.log('✅ [CRITICAL] User tenants reloaded successfully');
} else {
console.error('❌ [CRITICAL] Cannot reload tenants - user.id is missing!', { user });
}
// If tenant info and location are available in data, update the wizard context
if (data.tenantId && data.bakeryLocation) {
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
}
} else {
console.warn('⚠️ Setup step completed but no tenant data provided!');
}
}
if (currentStep.id === 'child-tenants-setup' && data?.childTenants) {
wizardContext.updateChildTenants(data.childTenants);
wizardContext.markStepComplete('childTenantsCompleted');
// Call backend API to create child tenants
try {
console.log('🏢 Creating child tenants in backend...');
const { tenantService } = await import('../../../api');
const parentTenantId = wizardContext.state.tenantId;
if (!parentTenantId) {
console.error('❌ Parent tenant ID not found in wizard context');
throw new Error('Parent tenant not registered');
}
const response = await tenantService.bulkCreateChildTenants({
parent_tenant_id: parentTenantId,
child_tenants: data.childTenants.map((ct: any) => ({
name: ct.name,
city: ct.city,
zone: ct.zone,
address: ct.address,
postal_code: ct.postal_code,
location_code: ct.location_code,
latitude: ct.latitude,
longitude: ct.longitude,
phone: ct.phone,
email: ct.email,
})),
auto_configure_distribution: true,
});
console.log('✅ Child tenants created successfully:', {
created: response.created_count,
failed: response.failed_count,
});
if (response.failed_count > 0) {
console.warn('⚠️ Some child tenants failed to create:', response.failed_tenants);
}
} catch (childTenantError) {
console.error('❌ Failed to create child tenants:', childTenantError);
// Don't block the onboarding flow - log the error and continue
// The user can add child tenants later from settings
}
}
@@ -485,7 +595,7 @@ const OnboardingWizardContent: React.FC = () => {
</div>
<div className="text-center sm:text-right">
<div className="text-sm text-[var(--text-secondary)]">
{t('onboarding:wizard.progress.step_of', 'Paso {{current}} de {{total}}', {
{t('onboarding:wizard.progress.step_of', 'Paso {current} de {total}', {
current: currentStepIndex + 1,
total: VISIBLE_STEPS.length
})}

View File

@@ -32,6 +32,17 @@ export interface InventoryItemForm {
};
}
// Child tenant interface for enterprise onboarding
export interface ChildTenantData {
id: string;
name: string;
city: string;
zone?: string;
address: string;
postal_code: string;
location_code: string;
}
export interface WizardState {
// Discovery Phase
bakeryType: BakeryType;
@@ -44,6 +55,10 @@ export interface WizardState {
longitude: number;
};
// Enterprise Setup Data
childTenants?: ChildTenantData[]; // Child tenant locations for enterprise tier
childTenantsCompleted: boolean;
// AI-Assisted Path Data
uploadedFile?: File; // NEW: The actual file object needed for sales import API
uploadedFileName?: string;
@@ -82,6 +97,7 @@ export interface WizardContextValue {
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
updateLocation: (location: { latitude: number; longitude: number }) => void;
updateTenantId: (tenantId: string) => void;
updateChildTenants: (tenants: ChildTenantData[]) => void; // NEW: Store child tenants
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
setAIAnalysisComplete: (complete: boolean) => void;
@@ -97,6 +113,8 @@ export interface WizardContextValue {
const initialState: WizardState = {
bakeryType: null,
dataSource: 'ai-assisted', // Only AI-assisted path supported now
childTenants: undefined,
childTenantsCompleted: false,
aiSuggestions: [],
aiAnalysisComplete: false,
categorizedProducts: undefined,
@@ -170,12 +188,16 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
};
const updateTenantId = (tenantId: string) => {
setState(prev => ({
...prev,
setState(prev => ({
...prev,
tenantId
}));
};
const updateChildTenants = (tenants: ChildTenantData[]) => {
setState(prev => ({ ...prev, childTenants: tenants }));
};
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
};
@@ -251,15 +273,11 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
steps.push('suppliers-setup');
steps.push('inventory-setup');
// Conditional: Recipes vs Processes
// Conditional: Recipes
if (state.bakeryType === 'production' || state.bakeryType === 'mixed') {
steps.push('recipes-setup');
}
if (state.bakeryType === 'retail' || state.bakeryType === 'mixed') {
steps.push('production-processes');
}
// Phase 3: Advanced Features (Optional)
steps.push('quality-setup');
steps.push('team-setup');
@@ -301,6 +319,7 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
updateTenantInfo,
updateLocation,
updateTenantId,
updateChildTenants,
updateAISuggestions,
updateUploadedFile,
setAIAnalysisComplete,

View File

@@ -0,0 +1,406 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Store, MapPin, Trash2, Edit2, Building2, X } from 'lucide-react';
import Button from '../../../ui/Button/Button';
import Card from '../../../ui/Card/Card';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../../../ui/Modal';
import { Input } from '../../../ui/Input';
export interface ChildTenantSetupStepProps {
onUpdate?: (data: { childTenants: ChildTenant[]; canContinue: boolean }) => void;
onComplete?: (data: { childTenants: ChildTenant[] }) => void;
initialData?: {
childTenants?: ChildTenant[];
};
}
export interface ChildTenant {
id: string;
name: string;
city: string;
zone?: string;
address: string;
postal_code: string;
location_code: string;
}
export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
onUpdate,
onComplete,
initialData,
}) => {
const { t } = useTranslation();
const [childTenants, setChildTenants] = useState<ChildTenant[]>(
initialData?.childTenants || []
);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTenant, setEditingTenant] = useState<ChildTenant | null>(null);
const [formData, setFormData] = useState<Partial<ChildTenant>>({
name: '',
city: '',
zone: '',
address: '',
postal_code: '',
location_code: '',
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// Notify parent when child tenants change
React.useEffect(() => {
onUpdate?.({
childTenants,
canContinue: childTenants.length > 0 // Require at least one child tenant
});
}, [childTenants, onUpdate]);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.name?.trim()) {
errors.name = 'El nombre es requerido';
}
if (!formData.city?.trim()) {
errors.city = 'La ciudad es requerida';
}
if (!formData.address?.trim()) {
errors.address = 'La dirección es requerida';
}
if (!formData.postal_code?.trim()) {
errors.postal_code = 'El código postal es requerido';
}
if (!formData.location_code?.trim()) {
errors.location_code = 'El código de ubicación es requerido';
} else if (formData.location_code.length > 10) {
errors.location_code = 'El código no debe exceder 10 caracteres';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleOpenModal = (tenant?: ChildTenant) => {
if (tenant) {
setEditingTenant(tenant);
setFormData({ ...tenant });
} else {
setEditingTenant(null);
setFormData({
name: '',
city: '',
zone: '',
address: '',
postal_code: '',
location_code: '',
});
}
setFormErrors({});
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingTenant(null);
setFormData({
name: '',
city: '',
zone: '',
address: '',
postal_code: '',
location_code: '',
});
setFormErrors({});
};
const handleSaveTenant = () => {
if (!validateForm()) return;
const tenant: ChildTenant = {
id: editingTenant?.id || `child-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: formData.name!,
city: formData.city!,
zone: formData.zone,
address: formData.address!,
postal_code: formData.postal_code!,
location_code: formData.location_code!.toUpperCase(),
};
if (editingTenant) {
// Update existing
setChildTenants(prev => prev.map(t => t.id === editingTenant.id ? tenant : t));
} else {
// Add new
setChildTenants(prev => [...prev, tenant]);
}
handleCloseModal();
};
const handleDeleteTenant = (id: string) => {
setChildTenants(prev => prev.filter(t => t.id !== id));
};
const handleContinue = () => {
if (childTenants.length === 0) {
alert('Debes agregar al menos una sucursal para continuar');
return;
}
onComplete?.({ childTenants });
};
return (
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-3">
<Building2 className="w-10 h-10 text-[var(--color-primary)]" />
<h1 className="text-2xl md:text-3xl font-bold text-[var(--text-primary)]">
Configuración de Sucursales
</h1>
</div>
<p className="text-base md:text-lg text-[var(--text-secondary)] max-w-2xl mx-auto">
Como empresa con tier Enterprise, tienes un obrador central y múltiples sucursales.
Agrega la información de cada sucursal que recibirá productos del obrador central.
</p>
</div>
{/* Info Box */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="text-[var(--color-info)] mt-0.5">
<Store className="w-5 h-5" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-[var(--text-primary)]">
Modelo de Negocio Enterprise
</h4>
<p className="text-sm text-[var(--text-secondary)]">
Tu obrador central se encargará de la producción, y las sucursales recibirán
los productos terminados mediante transferencias internas optimizadas.
</p>
</div>
</div>
</div>
{/* Child Tenants List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
Sucursales ({childTenants.length})
</h2>
<Button
onClick={() => handleOpenModal()}
variant="primary"
size="md"
leftIcon={<Plus className="w-4 h-4" />}
>
Agregar Sucursal
</Button>
</div>
{childTenants.length === 0 ? (
<Card className="p-8 text-center">
<div className="space-y-4">
<div className="w-16 h-16 mx-auto bg-[var(--bg-tertiary)] rounded-full flex items-center justify-center">
<Store className="w-8 h-8 text-[var(--text-tertiary)]" />
</div>
<div>
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay sucursales agregadas
</h3>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Comienza agregando las sucursales que forman parte de tu red empresarial
</p>
<Button
onClick={() => handleOpenModal()}
variant="primary"
size="lg"
leftIcon={<Plus className="w-5 h-5" />}
>
Agregar Primera Sucursal
</Button>
</div>
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{childTenants.map((tenant) => (
<Card key={tenant.id} className="p-4 hover:shadow-md transition-shadow">
<div className="space-y-3">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center">
<Store className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="font-semibold text-[var(--text-primary)]">
{tenant.name}
</h3>
<span className="text-xs text-[var(--text-tertiary)] font-mono">
{tenant.location_code}
</span>
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => handleOpenModal(tenant)}
className="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="Editar"
>
<Edit2 className="w-4 h-4 text-[var(--text-secondary)]" />
</button>
<button
onClick={() => handleDeleteTenant(tenant.id)}
className="p-2 hover:bg-[var(--color-error)]/10 rounded-lg transition-colors"
title="Eliminar"
>
<Trash2 className="w-4 h-4 text-[var(--color-error)]" />
</button>
</div>
</div>
{/* Location Details */}
<div className="space-y-1 text-sm">
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
<MapPin className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div>
<div>{tenant.address}</div>
<div>
{tenant.postal_code} - {tenant.city}
{tenant.zone && <>, {tenant.zone}</>}
</div>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Continue Button */}
{childTenants.length > 0 && (
<div className="flex justify-center pt-4">
<Button
onClick={handleContinue}
variant="primary"
size="lg"
className="w-full sm:w-auto sm:min-w-[200px]"
>
Continuar con {childTenants.length} {childTenants.length === 1 ? 'Sucursal' : 'Sucursales'}
</Button>
</div>
)}
{/* Add/Edit Modal */}
<Modal
isOpen={isModalOpen}
onClose={handleCloseModal}
size="lg"
>
<ModalHeader
title={editingTenant ? 'Editar Sucursal' : 'Agregar Sucursal'}
showCloseButton
onClose={handleCloseModal}
/>
<ModalBody padding="lg">
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre de la Sucursal *
</label>
<Input
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="ej. Madrid - Salamanca"
error={formErrors.name}
/>
</div>
{/* Location Code */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Código de Ubicación * (máx. 10 caracteres)
</label>
<Input
value={formData.location_code || ''}
onChange={(e) => setFormData({ ...formData, location_code: e.target.value.toUpperCase() })}
placeholder="ej. MAD, BCN, VAL"
maxLength={10}
error={formErrors.location_code}
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Un código corto para identificar esta ubicación
</p>
</div>
{/* City and Zone */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Ciudad *
</label>
<Input
value={formData.city || ''}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
placeholder="ej. Madrid"
error={formErrors.city}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Zona / Barrio
</label>
<Input
value={formData.zone || ''}
onChange={(e) => setFormData({ ...formData, zone: e.target.value })}
placeholder="ej. Salamanca"
/>
</div>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Dirección *
</label>
<Input
value={formData.address || ''}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="ej. Calle de Serrano, 48"
error={formErrors.address}
/>
</div>
{/* Postal Code */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Código Postal *
</label>
<Input
value={formData.postal_code || ''}
onChange={(e) => setFormData({ ...formData, postal_code: e.target.value })}
placeholder="ej. 28001"
error={formErrors.postal_code}
/>
</div>
</div>
</ModalBody>
<ModalFooter justify="end">
<div className="flex gap-3">
<Button variant="outline" onClick={handleCloseModal}>
Cancelar
</Button>
<Button variant="primary" onClick={handleSaveTenant}>
{editingTenant ? 'Guardar Cambios' : 'Agregar Sucursal'}
</Button>
</div>
</ModalFooter>
</Modal>
</div>
);
};
export default ChildTenantsSetupStep;

View File

@@ -20,12 +20,24 @@ export const CompletionStep: React.FC<CompletionStepProps> = ({
const navigate = useNavigate();
const currentTenant = useCurrentTenant();
const handleStartUsingSystem = () => {
const handleStartUsingSystem = async () => {
// CRITICAL: Ensure tenant access is loaded before navigating
console.log('🔄 [CompletionStep] Ensuring tenant setup is complete before dashboard navigation...');
// Small delay to ensure any pending state updates complete
await new Promise(resolve => setTimeout(resolve, 500));
onComplete({ redirectTo: '/app/dashboard' });
navigate('/app/dashboard');
};
const handleExploreDashboard = () => {
const handleExploreDashboard = async () => {
// CRITICAL: Ensure tenant access is loaded before navigating
console.log('🔄 [CompletionStep] Ensuring tenant setup is complete before dashboard navigation...');
// Small delay to ensure any pending state updates complete
await new Promise(resolve => setTimeout(resolve, 500));
onComplete({ redirectTo: '/app/dashboard' });
navigate('/app/dashboard');
};

View File

@@ -1,398 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, X, Clock, Flame, ChefHat } from 'lucide-react';
import Button from '../../../ui/Button/Button';
import Card from '../../../ui/Card/Card';
import Input from '../../../ui/Input/Input';
import Select from '../../../ui/Select/Select';
export interface ProductionProcess {
id: string;
name: string;
sourceProduct: string;
finishedProduct: string;
processType: 'baking' | 'decorating' | 'finishing' | 'assembly';
duration: number; // minutes
temperature?: number; // celsius
instructions?: string;
}
export interface ProductionProcessesStepProps {
onUpdate?: (data: { processes: ProductionProcess[] }) => void;
onComplete?: () => void;
initialData?: {
processes?: ProductionProcess[];
};
}
const PROCESS_TEMPLATES: Partial<ProductionProcess>[] = [
{
name: 'Horneado de Pan Pre-cocido',
processType: 'baking',
duration: 15,
temperature: 200,
instructions: 'Hornear a 200°C durante 15 minutos hasta dorar',
},
{
name: 'Terminado de Croissant Congelado',
processType: 'baking',
duration: 20,
temperature: 180,
instructions: 'Descongelar 2h, hornear a 180°C por 20 min',
},
{
name: 'Decoración de Pastel',
processType: 'decorating',
duration: 30,
instructions: 'Aplicar crema, decorar y refrigerar',
},
{
name: 'Montaje de Sándwich',
processType: 'assembly',
duration: 5,
instructions: 'Ensamblar ingredientes según especificación',
},
];
export const ProductionProcessesStep: React.FC<ProductionProcessesStepProps> = ({
onUpdate,
onComplete,
initialData,
}) => {
const { t } = useTranslation();
const [processes, setProcesses] = useState<ProductionProcess[]>(
initialData?.processes || []
);
const [isAddingNew, setIsAddingNew] = useState(false);
const [showTemplates, setShowTemplates] = useState(true);
const [newProcess, setNewProcess] = useState<Partial<ProductionProcess>>({
name: '',
sourceProduct: '',
finishedProduct: '',
processType: 'baking',
duration: 15,
temperature: 180,
instructions: '',
});
const processTypeOptions = [
{ value: 'baking', label: t('onboarding:processes.type.baking', 'Horneado') },
{ value: 'decorating', label: t('onboarding:processes.type.decorating', 'Decoración') },
{ value: 'finishing', label: t('onboarding:processes.type.finishing', 'Terminado') },
{ value: 'assembly', label: t('onboarding:processes.type.assembly', 'Montaje') },
];
const handleAddFromTemplate = (template: Partial<ProductionProcess>) => {
const newProc: ProductionProcess = {
id: `process-${Date.now()}`,
name: template.name || '',
sourceProduct: '',
finishedProduct: '',
processType: template.processType || 'baking',
duration: template.duration || 15,
temperature: template.temperature,
instructions: template.instructions || '',
};
const updated = [...processes, newProc];
setProcesses(updated);
onUpdate?.({ processes: updated });
setShowTemplates(false);
};
const handleAddNew = () => {
if (!newProcess.name || !newProcess.sourceProduct || !newProcess.finishedProduct) {
return;
}
const process: ProductionProcess = {
id: `process-${Date.now()}`,
name: newProcess.name,
sourceProduct: newProcess.sourceProduct,
finishedProduct: newProcess.finishedProduct,
processType: newProcess.processType || 'baking',
duration: newProcess.duration || 15,
temperature: newProcess.temperature,
instructions: newProcess.instructions || '',
};
const updated = [...processes, process];
setProcesses(updated);
onUpdate?.({ processes: updated });
// Reset form
setNewProcess({
name: '',
sourceProduct: '',
finishedProduct: '',
processType: 'baking',
duration: 15,
temperature: 180,
instructions: '',
});
setIsAddingNew(false);
};
const handleRemove = (id: string) => {
const updated = processes.filter(p => p.id !== id);
setProcesses(updated);
onUpdate?.({ processes: updated });
};
const handleContinue = () => {
onComplete?.();
};
const getProcessIcon = (type: string) => {
switch (type) {
case 'baking':
return <Flame className="w-5 h-5 text-orange-500" />;
case 'decorating':
return <ChefHat className="w-5 h-5 text-pink-500" />;
case 'finishing':
case 'assembly':
return <Clock className="w-5 h-5 text-blue-500" />;
default:
return <Clock className="w-5 h-5 text-gray-500" />;
}
};
return (
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-4 md:space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<h1 className="text-xl md:text-2xl font-bold text-text-primary px-2">
{t('onboarding:processes.title', 'Procesos de Producción')}
</h1>
<p className="text-sm md:text-base text-text-secondary px-4">
{t(
'onboarding:processes.subtitle',
'Define los procesos que usas para transformar productos pre-elaborados en productos terminados'
)}
</p>
</div>
{/* Templates Section */}
{showTemplates && processes.length === 0 && (
<Card className="p-6 space-y-4 bg-gradient-to-br from-blue-50 to-cyan-50">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-semibold text-text-primary">
{t('onboarding:processes.templates.title', '⚡ Comienza rápido con plantillas')}
</h3>
<p className="text-sm text-text-secondary">
{t('onboarding:processes.templates.subtitle', 'Haz clic en una plantilla para agregarla')}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowTemplates(false)}
>
{t('onboarding:processes.templates.hide', 'Ocultar')}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{PROCESS_TEMPLATES.map((template, index) => (
<button
key={index}
onClick={() => handleAddFromTemplate(template)}
className="p-4 text-left bg-white border border-border-primary rounded-lg hover:shadow-md hover:border-primary-300 transition-all"
>
<div className="space-y-2">
<div className="flex items-center gap-2">
{getProcessIcon(template.processType || 'baking')}
<span className="font-medium text-text-primary">{template.name}</span>
</div>
<div className="flex items-center gap-3 text-xs text-text-secondary">
<span> {template.duration} min</span>
{template.temperature && <span>🌡 {template.temperature}°C</span>}
</div>
</div>
</button>
))}
</div>
</Card>
)}
{/* Existing Processes */}
{processes.length > 0 && (
<div className="space-y-3">
<h3 className="font-semibold text-text-primary">
{t('onboarding:processes.your_processes', 'Tus Procesos')} ({processes.length})
</h3>
<div className="space-y-2">
{processes.map((process) => (
<Card key={process.id} className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
{getProcessIcon(process.processType)}
<h4 className="font-semibold text-text-primary">{process.name}</h4>
</div>
<div className="text-sm text-text-secondary space-y-1">
{process.sourceProduct && (
<p>
<span className="font-medium">
{t('onboarding:processes.source', 'Desde')}:
</span>{' '}
{process.sourceProduct}
</p>
)}
{process.finishedProduct && (
<p>
<span className="font-medium">
{t('onboarding:processes.finished', 'Hasta')}:
</span>{' '}
{process.finishedProduct}
</p>
)}
<div className="flex items-center gap-3 pt-1">
<span> {process.duration} min</span>
{process.temperature && <span>🌡 {process.temperature}°C</span>}
</div>
{process.instructions && (
<p className="text-xs italic pt-1">{process.instructions}</p>
)}
</div>
</div>
<button
onClick={() => handleRemove(process.id)}
className="text-text-secondary hover:text-red-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</Card>
))}
</div>
</div>
)}
{/* Add New Process Form */}
{isAddingNew && (
<Card className="p-6 space-y-4 border-2 border-primary-300">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-text-primary">
{t('onboarding:processes.add_new', 'Nuevo Proceso')}
</h3>
<button
onClick={() => setIsAddingNew(false)}
className="text-text-secondary hover:text-text-primary"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Input
label={t('onboarding:processes.form.name', 'Nombre del Proceso')}
value={newProcess.name || ''}
onChange={(e) => setNewProcess({ ...newProcess, name: e.target.value })}
placeholder={t('onboarding:processes.form.name_placeholder', 'Ej: Horneado de pan')}
required
/>
</div>
<Input
label={t('onboarding:processes.form.source', 'Producto Origen')}
value={newProcess.sourceProduct || ''}
onChange={(e) => setNewProcess({ ...newProcess, sourceProduct: e.target.value })}
placeholder={t('onboarding:processes.form.source_placeholder', 'Ej: Pan pre-cocido')}
required
/>
<Input
label={t('onboarding:processes.form.finished', 'Producto Terminado')}
value={newProcess.finishedProduct || ''}
onChange={(e) => setNewProcess({ ...newProcess, finishedProduct: e.target.value })}
placeholder={t('onboarding:processes.form.finished_placeholder', 'Ej: Pan fresco')}
required
/>
<Select
label={t('onboarding:processes.form.type', 'Tipo de Proceso')}
value={newProcess.processType || 'baking'}
onChange={(e) => setNewProcess({ ...newProcess, processType: e.target.value as any })}
options={processTypeOptions}
/>
<Input
type="number"
label={t('onboarding:processes.form.duration', 'Duración (minutos)')}
value={newProcess.duration || 15}
onChange={(e) => setNewProcess({ ...newProcess, duration: parseInt(e.target.value) })}
min={1}
/>
{(newProcess.processType === 'baking' || newProcess.processType === 'finishing') && (
<Input
type="number"
label={t('onboarding:processes.form.temperature', 'Temperatura (°C)')}
value={newProcess.temperature || ''}
onChange={(e) => setNewProcess({ ...newProcess, temperature: parseInt(e.target.value) || undefined })}
placeholder="180"
/>
)}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1">
{t('onboarding:processes.form.instructions', 'Instrucciones (opcional)')}
</label>
<textarea
value={newProcess.instructions || ''}
onChange={(e) => setNewProcess({ ...newProcess, instructions: e.target.value })}
placeholder={t('onboarding:processes.form.instructions_placeholder', 'Describe el proceso...')}
rows={3}
className="w-full px-3 py-2 border border-border-primary rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setIsAddingNew(false)}>
{t('onboarding:processes.form.cancel', 'Cancelar')}
</Button>
<Button
onClick={handleAddNew}
disabled={!newProcess.name || !newProcess.sourceProduct || !newProcess.finishedProduct}
>
{t('onboarding:processes.form.add', 'Agregar Proceso')}
</Button>
</div>
</Card>
)}
{/* Add Button */}
{!isAddingNew && (
<Button
onClick={() => setIsAddingNew(true)}
variant="outline"
className="w-full border-dashed"
>
<Plus className="w-5 h-5 mr-2" />
{t('onboarding:processes.add_button', 'Agregar Proceso')}
</Button>
)}
{/* Footer Actions */}
<div className="flex items-center justify-between pt-6 border-t border-border-primary">
<p className="text-sm text-text-secondary">
{processes.length === 0
? t('onboarding:processes.hint', '💡 Agrega al menos un proceso para continuar')
: t('onboarding:processes.count', `${processes.length} proceso(s) configurado(s)`)}
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={handleContinue}>
{t('onboarding:processes.skip', 'Omitir por ahora')}
</Button>
<Button onClick={handleContinue} disabled={processes.length === 0}>
{t('onboarding:processes.continue', 'Continuar')}
</Button>
</div>
</div>
</div>
);
};
export default ProductionProcessesStep;

View File

@@ -15,11 +15,33 @@ interface RegisterTenantStepProps {
isLastStep: boolean;
}
// Map bakeryType to business_model
const getBakeryBusinessModel = (bakeryType: string | null): string => {
switch (bakeryType) {
case 'production':
return 'central_baker_satellite'; // Production-focused bakery
case 'retail':
return 'retail_bakery'; // Retail/finishing bakery
case 'mixed':
return 'hybrid_bakery'; // Mixed model (enterprise or hybrid)
default:
return 'individual_bakery'; // Default fallback
}
};
export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
onComplete,
isFirstStep
}) => {
const wizardContext = useWizardContext();
// Check if user is enterprise tier for conditional labels
const subscriptionTier = localStorage.getItem('subscription_tier');
const isEnterprise = subscriptionTier === 'enterprise';
// Get business_model from wizard context's bakeryType
const businessModel = getBakeryBusinessModel(wizardContext.state.bakeryType);
const [formData, setFormData] = useState<BakeryRegistration>({
name: '',
address: '',
@@ -27,9 +49,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
phone: '',
city: 'Madrid',
business_type: 'bakery',
business_model: 'individual_bakery'
business_model: businessModel
});
// Update business_model when bakeryType changes in context
React.useEffect(() => {
const newBusinessModel = getBakeryBusinessModel(wizardContext.state.bakeryType);
if (newBusinessModel !== formData.business_model) {
setFormData(prev => ({
...prev,
business_model: newBusinessModel
}));
}
}, [wizardContext.state.bakeryType, formData.business_model]);
const [errors, setErrors] = useState<Record<string, string>>({});
const registerBakery = useRegisterBakery();
@@ -110,6 +143,12 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
return;
}
console.log('📝 Registering tenant with data:', {
bakeryType: wizardContext.state.bakeryType,
business_model: formData.business_model,
formData
});
try {
const tenant = await registerBakery.mutateAsync(formData);
@@ -167,8 +206,8 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
<div className="space-y-4 md:space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
<Input
label="Nombre de la Panadería"
placeholder="Ingresa el nombre de tu panadería"
label={isEnterprise ? "Nombre del Obrador Central" : "Nombre de la Panadería"}
placeholder={isEnterprise ? "Ingresa el nombre de tu obrador central" : "Ingresa el nombre de tu panadería"}
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
@@ -191,7 +230,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
</label>
<AddressAutocomplete
value={formData.address}
placeholder="Enter bakery address..."
placeholder={isEnterprise ? "Dirección del obrador central..." : "Dirección de tu panadería..."}
onAddressSelect={(address) => {
console.log('Selected:', address.display_name);
handleAddressSelect(address);
@@ -236,10 +275,10 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
<Button
onClick={handleSubmit}
isLoading={registerBakery.isPending}
loadingText="Registrando..."
loadingText={isEnterprise ? "Registrando obrador..." : "Registrando..."}
size="lg"
>
Crear Panadería y Continuar
{isEnterprise ? "Crear Obrador Central y Continuar" : "Crear Panadería y Continuar"}
</Button>
</div>
</div>

View File

@@ -5,6 +5,7 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
// Core Onboarding Steps
export { RegisterTenantStep } from './RegisterTenantStep';
export { POIDetectionStep } from './POIDetectionStep';
export { ChildTenantsSetupStep } from './ChildTenantsSetupStep';
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
export { FileUploadStep } from './FileUploadStep';
@@ -17,9 +18,6 @@ export { UploadSalesDataStep } from './UploadSalesDataStep';
export { default as ProductCategorizationStep } from './ProductCategorizationStep';
export { default as InitialStockEntryStep } from './InitialStockEntryStep';
// Production Steps
export { default as ProductionProcessesStep } from './ProductionProcessesStep';
// ML & Finalization
export { MLTrainingStep } from './MLTrainingStep';
export { CompletionStep } from './CompletionStep';

View File

@@ -32,7 +32,7 @@ export const StepProgress: React.FC<StepProgressProps> = ({
</div>
<div className="text-center sm:text-right">
<div className="text-sm text-[var(--text-secondary)]">
{t('setup_wizard:progress.step_of', 'Step {{current}} of {{total}}', {
{t('setup_wizard:progress.step_of', 'Step {current} of {total}', {
current: currentStepIndex + 1,
total: steps.length
})}

View File

@@ -1,18 +1,18 @@
import React, { useState, useEffect, useMemo } from 'react';
import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo';
import { destroyDemoSession } from '../../../api/services/demo';
import { apiClient } from '../../../api/client';
import { useNavigate } from 'react-router-dom';
import { useDemoTour, getTourState, trackTourEvent } from '../../../features/demo-onboarding';
import { BookOpen, Clock, Sparkles, X } from 'lucide-react';
import { BookOpen, Check, Clock, Sparkles, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const DemoBanner: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation('demo');
const { startTour, resumeTour } = useDemoTour();
const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true');
const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at'));
const [timeRemaining, setTimeRemaining] = useState<string>('');
const [canExtend, setCanExtend] = useState(true);
const [extending, setExtending] = useState(false);
const [showExitModal, setShowExitModal] = useState(false);
// Memoize tour state to prevent re-renders
@@ -42,7 +42,7 @@ export const DemoBanner: React.FC = () => {
const diff = expiryTime - now;
if (diff <= 0) {
setTimeRemaining('Sesión expirada');
setTimeRemaining(t('banner.session_expired', 'Sesión expirada'));
await handleExpiration();
} else {
const minutes = Math.floor(diff / 60000);
@@ -81,27 +81,6 @@ export const DemoBanner: React.FC = () => {
navigate('/demo');
};
const handleExtendSession = async () => {
const sessionId = apiClient.getDemoSessionId();
if (!sessionId) return;
setExtending(true);
try {
const updatedSession = await extendDemoSession({ session_id: sessionId });
localStorage.setItem('demo_expires_at', updatedSession.expires_at);
setExpiresAt(updatedSession.expires_at);
if (updatedSession.remaining_extensions === 0) {
setCanExtend(false);
}
} catch (error) {
console.error('Error extending session:', error);
alert('No se pudo extender la sesión');
} finally {
setExtending(false);
}
};
const handleEndSession = async () => {
setShowExitModal(true);
};
@@ -153,7 +132,7 @@ export const DemoBanner: React.FC = () => {
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
<span className="font-bold text-base">Sesión Demo Activa</span>
<span className="font-bold text-base">{t('banner.active_session')}</span>
</div>
<div className="flex items-center gap-2 text-sm bg-white/20 rounded-md px-3 py-1">
@@ -163,7 +142,7 @@ export const DemoBanner: React.FC = () => {
{tourState && !tourState.completed && tourState.currentStep > 0 && (
<div className="hidden md:flex items-center gap-2 text-sm bg-white/20 rounded-md px-3 py-1">
<span>Tutorial pausado en paso {tourState.currentStep}</span>
<span>{t('banner.tutorial_paused')} {tourState.currentStep}</span>
</div>
)}
</div>
@@ -178,10 +157,14 @@ export const DemoBanner: React.FC = () => {
<BookOpen className="w-4 h-4" />
<span className="hidden sm:inline">
{tourState && tourState.currentStep > 0 && !tourState.completed
? 'Continuar Tutorial'
: 'Ver Tutorial'}
? t('banner.continue_tutorial')
: t('banner.tutorial_button')}
</span>
<span className="sm:hidden">
{tourState && tourState.currentStep > 0 && !tourState.completed
? t('banner.continue_tutorial')
: t('banner.tutorial_button')}
</span>
<span className="sm:hidden">Tutorial</span>
</button>
{/* Create account CTA */}
@@ -190,27 +173,15 @@ export const DemoBanner: React.FC = () => {
className="inline-flex items-center gap-2 px-4 py-2 bg-white text-orange-600 rounded-lg text-sm font-bold hover:bg-orange-50 transition-all shadow-sm hover:shadow-md animate-pulse hover:animate-none"
>
<Sparkles className="w-4 h-4" />
<span className="hidden lg:inline">¡Crear Cuenta Gratis!</span>
<span className="lg:hidden">Crear Cuenta</span>
<span>{t('banner.create_account')}</span>
</button>
{/* Extend session */}
{canExtend && (
<button
onClick={handleExtendSession}
disabled={extending}
className="px-3 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 hidden sm:block"
>
{extending ? 'Extendiendo...' : '+30 min'}
</button>
)}
{/* End session */}
<button
onClick={handleEndSession}
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm font-medium transition-colors"
>
<span className="hidden sm:inline">Salir</span>
<span className="hidden sm:inline">{t('banner.exit')}</span>
<X className="w-4 h-4 sm:hidden" />
</button>
</div>
@@ -228,26 +199,39 @@ export const DemoBanner: React.FC = () => {
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">
¿Seguro que quieres salir?
{t('exit_modal.title')}
</h3>
<p className="text-[var(--text-secondary)] text-sm leading-relaxed">
Aún te quedan <span className="font-bold text-amber-600">{timeRemaining}</span> de sesión demo.
{t('exit_modal.time_left')} <span className="font-bold text-amber-600">{timeRemaining}</span> {t('exit_modal.time_left_suffix')}
</p>
</div>
</div>
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 rounded-xl p-4 mb-6 border border-amber-200 dark:border-amber-800">
<p className="text-sm font-semibold text-[var(--text-primary)] mb-3">
¿Te gusta lo que ves?
</p>
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 rounded-xl p-5 mb-6 border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<p className="text-base font-bold text-[var(--text-primary)]">
{t('exit_modal.cta_title')}
</p>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-4 leading-relaxed">
Crea una cuenta <span className="font-bold">gratuita</span> para acceder a todas las funcionalidades sin límites de tiempo y guardar tus datos de forma permanente.
{t('exit_modal.cta_description')}
</p>
<div className="space-y-2 mb-4">
<div className="flex items-start gap-2 text-sm text-[var(--text-primary)]">
<Check className="w-4 h-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
<span><span className="font-semibold">{t('exit_modal.benefits.save_data')}</span> {t('exit_modal.benefits.save_data_suffix')}</span>
</div>
<div className="flex items-start gap-2 text-sm text-[var(--text-primary)]">
<Check className="w-4 h-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
<span><span className="font-semibold">{t('exit_modal.benefits.full_access')}</span> {t('exit_modal.benefits.full_access_suffix')}</span>
</div>
</div>
<button
onClick={handleCreateAccount}
className="w-full py-3 bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-lg font-bold hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
>
Crear Mi Cuenta Gratis
{t('exit_modal.create_account_button')}
</button>
</div>
@@ -256,13 +240,13 @@ export const DemoBanner: React.FC = () => {
onClick={() => setShowExitModal(false)}
className="flex-1 px-4 py-2.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)] rounded-lg font-semibold transition-colors border border-[var(--border-default)]"
>
Seguir en Demo
{t('exit_modal.continue_demo')}
</button>
<button
onClick={confirmEndSession}
className="flex-1 px-4 py-2.5 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 rounded-lg font-semibold transition-colors border border-red-200 dark:border-red-800"
>
Salir de Demo
{t('exit_modal.exit_demo')}
</button>
</div>
</div>

View File

@@ -17,7 +17,7 @@ export const getDriverConfig = (
closeBtnText: '×',
nextBtnText: 'Siguiente →',
prevBtnText: '← Anterior',
progressText: 'Paso {{current}} de {{total}}',
progressText: 'Paso {current} de {total}',
popoverClass: 'bakery-tour-popover',
popoverOffset: 10,

View File

@@ -61,5 +61,31 @@
"errors": {
"loading_accounts": "Error loading demo accounts",
"creating_session": "Error creating demo session"
},
"banner": {
"active_session": "Active Demo Session",
"time_remaining": "Time remaining",
"session_expired": "Session expired",
"tutorial_button": "View Tutorial",
"continue_tutorial": "Continue Tutorial",
"tutorial_paused": "Tutorial paused at step",
"create_account": "Create Account",
"exit": "Exit"
},
"exit_modal": {
"title": "Are you sure you want to exit?",
"time_left": "You still have",
"time_left_suffix": "of demo session left.",
"cta_title": "Like what you see?",
"cta_description": "Join hundreds of bakeries already optimizing their production, reducing costs and improving profitability with our professional system.",
"benefits": {
"save_data": "Save your data",
"save_data_suffix": "permanently and securely",
"full_access": "Full access",
"full_access_suffix": "to all professional features"
},
"create_account_button": "Create Account",
"continue_demo": "Continue Demo",
"exit_demo": "Exit Demo"
}
}

View File

@@ -5,7 +5,9 @@
"steps": {
"setup": {
"title": "Register Bakery",
"description": "Configure your bakery's basic information"
"title_enterprise": "Register Central Bakery",
"description": "Configure your bakery's basic information",
"description_enterprise": "Central bakery information"
},
"poi_detection": {
"title": "Location Analysis",

View File

@@ -61,5 +61,31 @@
"errors": {
"loading_accounts": "Error al cargar las cuentas demo",
"creating_session": "Error al crear sesión demo"
},
"banner": {
"active_session": "Sesión Demo Activa",
"time_remaining": "Tiempo restante",
"session_expired": "Sesión expirada",
"tutorial_button": "Ver Tutorial",
"continue_tutorial": "Continuar Tutorial",
"tutorial_paused": "Tutorial pausado en paso",
"create_account": "Crear Cuenta",
"exit": "Salir"
},
"exit_modal": {
"title": "¿Seguro que quieres salir?",
"time_left": "Aún te quedan",
"time_left_suffix": "de sesión demo.",
"cta_title": "¿Te gusta lo que ves?",
"cta_description": "Únete a cientos de panaderías que ya optimizan su producción, reducen costos y mejoran su rentabilidad con nuestro sistema profesional.",
"benefits": {
"save_data": "Guarda tus datos",
"save_data_suffix": "de forma permanente y segura",
"full_access": "Acceso completo",
"full_access_suffix": "a todas las funcionalidades profesionales"
},
"create_account_button": "Crear Cuenta",
"continue_demo": "Seguir en Demo",
"exit_demo": "Salir de Demo"
}
}

View File

@@ -14,7 +14,9 @@
},
"setup": {
"title": "Registrar Panadería",
"description": "Información básica"
"title_enterprise": "Registrar Obrador Central",
"description": "Información básica",
"description_enterprise": "Información del obrador central"
},
"poi_detection": {
"title": "Análisis de Ubicación",

View File

@@ -61,5 +61,31 @@
"errors": {
"loading_accounts": "Errorea demo kontuak kargatzean",
"creating_session": "Errorea demo saioa sortzean"
},
"banner": {
"active_session": "Demo Saio Aktiboa",
"time_remaining": "Gelditzen den denbora",
"session_expired": "Saioa iraungita",
"tutorial_button": "Tutoriala Ikusi",
"continue_tutorial": "Tutoriala Jarraitu",
"tutorial_paused": "Tutoriala pausatua urrats honetan",
"create_account": "Kontua Sortu",
"exit": "Irten"
},
"exit_modal": {
"title": "Ziur irten nahi duzula?",
"time_left": "Oraindik",
"time_left_suffix": "demo saioa geratzen zaizu.",
"cta_title": "Gustatzen zaizu ikusten duzuna?",
"cta_description": "Bat egin gure sistema profesionalarekin dagoeneko ekoizpena optimizatzen, kostuak murrizten eta errentagarritasuna hobetzen duten ehunka okindegiarekin.",
"benefits": {
"save_data": "Gorde zure datuak",
"save_data_suffix": "modu iraunkor eta seguruan",
"full_access": "Sarbide osoa",
"full_access_suffix": "funtzionalitate profesional guztietara"
},
"create_account_button": "Kontua Sortu",
"continue_demo": "Demoan Jarraitu",
"exit_demo": "Demotik Irten"
}
}

View File

@@ -13,7 +13,9 @@
},
"setup": {
"title": "Okindegia Erregistratu",
"description": "Oinarrizko informazioa"
"title_enterprise": "Okindegi Zentrala Erregistratu",
"description": "Oinarrizko informazioa",
"description_enterprise": "Okindegi zentralaren informazioa"
},
"poi_detection": {
"title": "Kokapen Analisia",

View File

@@ -1,11 +1,8 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../../../components/layout';
import { Button } from '../../../../components/ui/Button';
import { Card, CardHeader, CardBody } from '../../../../components/ui/Card';
import { Badge } from '../../../../components/ui/Badge';
import { Tooltip } from '../../../../components/ui/Tooltip';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, EmptyState } from '../../../../components/ui';
import { useTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import {
@@ -13,12 +10,6 @@ import {
Building2,
Settings,
Users,
Calendar,
MapPin,
Phone,
Mail,
Globe,
MoreHorizontal,
ArrowRight,
Crown,
Shield,
@@ -31,6 +22,8 @@ const OrganizationsPage: React.FC = () => {
const user = useAuthUser();
const { currentTenant, availableTenants, switchTenant } = useTenant();
const [isLoading, setIsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const handleAddNewOrganization = () => {
navigate('/app/onboarding?new=true');
@@ -44,30 +37,14 @@ const OrganizationsPage: React.FC = () => {
setIsLoading(false);
};
const handleManageTenant = (tenantId: string) => {
// Navigate to tenant settings
const handleManageTenant = () => {
navigate(`/app/database/bakery-config`);
};
const handleManageTeam = (tenantId: string) => {
// Navigate to team management
const handleManageTeam = () => {
navigate(`/app/database/team`);
};
const getRoleIcon = (ownerId: string) => {
if (user?.id === ownerId) {
return <Crown className="w-4 h-4 text-[var(--color-warning)]" />;
}
return <Shield className="w-4 h-4 text-[var(--color-primary)]" />;
};
const getRoleLabel = (ownerId: string) => {
if (user?.id === ownerId) {
return 'Propietario';
}
return 'Miembro';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
year: 'numeric',
@@ -76,208 +53,197 @@ const OrganizationsPage: React.FC = () => {
});
};
const isOwner = (ownerId: string) => user?.id === ownerId;
// Filter organizations based on search and role
const filteredTenants = useMemo(() => {
let filtered = availableTenants || [];
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(tenant =>
tenant.name.toLowerCase().includes(searchLower) ||
tenant.business_type?.toLowerCase().includes(searchLower) ||
tenant.city?.toLowerCase().includes(searchLower)
);
}
if (roleFilter) {
filtered = filtered.filter(tenant => {
if (roleFilter === 'owner') return isOwner(tenant.owner_id);
if (roleFilter === 'member') return !isOwner(tenant.owner_id);
return true;
});
}
return filtered;
}, [availableTenants, searchTerm, roleFilter, user?.id]);
// Calculate stats
const stats = useMemo(() => [
{
title: 'Organizaciones Totales',
value: availableTenants?.length || 0,
variant: 'default' as const,
icon: Building2,
},
{
title: 'Como Propietario',
value: availableTenants?.filter(t => isOwner(t.owner_id)).length || 0,
variant: 'warning' as const,
icon: Crown,
},
{
title: 'Como Miembro',
value: availableTenants?.filter(t => !isOwner(t.owner_id)).length || 0,
variant: 'info' as const,
icon: Shield,
},
], [availableTenants, user?.id]);
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="space-y-6">
<PageHeader
title={t('settings:organization.title', 'Mis Organizaciones')}
description={t('settings:organization.description', 'Gestiona tus panaderías y negocios')}
actions={
<Button
onClick={handleAddNewOrganization}
variant="primary"
size="lg"
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Nueva Organización
</Button>
}
title="Organizaciones"
description="Gestiona tus organizaciones y configuraciones"
actions={[
{
id: "add-organization",
label: "Nueva Organización",
variant: "primary" as const,
icon: Plus,
onClick: handleAddNewOrganization,
tooltip: "Crear nueva organización",
size: "md"
}
]}
/>
{/* Statistics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardBody className="text-center">
<Building2 className="w-8 h-8 text-[var(--color-primary)] mx-auto mb-2" />
<div className="text-2xl font-bold text-[var(--text-primary)]">
{availableTenants?.length || 0}
</div>
<div className="text-sm text-[var(--text-secondary)]">Organizaciones Totales</div>
</CardBody>
</Card>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
<Card>
<CardBody className="text-center">
<Crown className="w-8 h-8 text-[var(--color-warning)] mx-auto mb-2" />
<div className="text-2xl font-bold text-[var(--text-primary)]">
{availableTenants?.filter(t => t.owner_id === user?.id).length || 0}
</div>
<div className="text-sm text-[var(--text-secondary)]">Propietario</div>
</CardBody>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar por nombre, tipo o ciudad..."
filters={[
{
key: 'role',
label: 'Rol',
type: 'dropdown',
value: roleFilter,
onChange: (value) => setRoleFilter(value as string),
placeholder: 'Todos los roles',
options: [
{ value: 'owner', label: 'Propietario' },
{ value: 'member', label: 'Miembro' }
]
}
]}
/>
<Card>
<CardBody className="text-center">
<Shield className="w-8 h-8 text-[var(--color-primary)] mx-auto mb-2" />
<div className="text-2xl font-bold text-[var(--text-primary)]">
{availableTenants?.filter(t => t.owner_id !== user?.id).length || 0}
</div>
<div className="text-sm text-[var(--text-secondary)]">Miembro</div>
</CardBody>
</Card>
</div>
{/* Organizations Grid */}
{filteredTenants && filteredTenants.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredTenants.map((tenant) => {
const isActive = currentTenant?.id === tenant.id;
const isOwnerRole = isOwner(tenant.owner_id);
{/* Organizations List */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Tus Organizaciones</h3>
const statusConfig = {
color: isActive ? getStatusColor('completed') : getStatusColor('default'),
text: isActive ? 'Activa' : 'Inactiva',
icon: isOwnerRole ? Crown : Shield,
isCritical: false,
isHighlight: isActive
};
{availableTenants && availableTenants.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{availableTenants.map((tenant) => (
<Card
return (
<StatusCard
key={tenant.id}
className={`transition-all duration-200 hover:shadow-lg ${
currentTenant?.id === tenant.id
? 'ring-2 ring-[var(--color-primary)]/20 bg-[var(--color-primary)]/5'
: 'hover:border-[var(--color-primary)]/30'
}`}
>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center flex-shrink-0">
<Building2 className="w-6 h-6 text-[var(--color-primary)]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-[var(--text-primary)] truncate">
{tenant.name}
</h4>
{currentTenant?.id === tenant.id && (
<Badge variant="primary" size="sm">Activa</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
{getRoleIcon(tenant.owner_id)}
<span>{getRoleLabel(tenant.owner_id)}</span>
</div>
</div>
</div>
<div className="flex gap-1">
<Tooltip content="Configurar organización">
<Button
variant="ghost"
size="sm"
onClick={() => handleManageTenant(tenant.id)}
className="w-8 h-8 p-0"
>
<Settings className="w-4 h-4" />
</Button>
</Tooltip>
{user?.id === tenant.owner_id && (
<Tooltip content="Gestionar equipo">
<Button
variant="ghost"
size="sm"
onClick={() => handleManageTeam(tenant.id)}
className="w-8 h-8 p-0"
>
<Users className="w-4 h-4" />
</Button>
</Tooltip>
)}
</div>
</CardHeader>
<CardBody className="pt-0">
{/* Organization details */}
<div className="space-y-2 mb-4">
{tenant.business_type && (
<div className="text-sm text-[var(--text-secondary)]">
<Badge variant="outline" size="sm">{tenant.business_type}</Badge>
</div>
)}
{tenant.address && (
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<MapPin className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{tenant.address}</span>
</div>
)}
{tenant.city && (
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<MapPin className="w-4 h-4 flex-shrink-0" />
<span>{tenant.city}</span>
</div>
)}
{tenant.phone && (
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<Phone className="w-4 h-4 flex-shrink-0" />
<span>{tenant.phone}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>Creada el {formatDate(tenant.created_at)}</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
{currentTenant?.id !== tenant.id ? (
<Button
onClick={() => handleSwitchToTenant(tenant.id)}
variant="primary"
size="sm"
disabled={isLoading}
className="flex items-center gap-2"
>
<ArrowRight className="w-4 h-4" />
Cambiar a esta organización
</Button>
) : (
<Button
onClick={() => navigate('/app/dashboard')}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Eye className="w-4 h-4" />
Ver dashboard
</Button>
)}
</div>
</CardBody>
</Card>
))}
</div>
) : (
<Card>
<CardBody className="text-center py-12">
<Building2 className="w-16 h-16 text-[var(--text-tertiary)] mx-auto mb-4" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
No tienes organizaciones
</h3>
<p className="text-[var(--text-secondary)] mb-6">
Crea tu primera organización para comenzar a usar Bakery IA
</p>
<Button
onClick={handleAddNewOrganization}
variant="primary"
size="lg"
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Crear Primera Organización
</Button>
</CardBody>
</Card>
)}
</div>
id={tenant.id}
statusIndicator={statusConfig}
title={tenant.name}
subtitle={tenant.business_type || 'Organización'}
primaryValue={isOwnerRole ? 'Propietario' : 'Miembro'}
primaryValueLabel=""
secondaryInfo={
tenant.city ? {
label: 'Ubicación',
value: tenant.city
} : undefined
}
metadata={[
`Creada ${formatDate(tenant.created_at)}`,
...(tenant.address ? [tenant.address] : []),
...(tenant.phone ? [tenant.phone] : [])
]}
onClick={() => {
if (!isActive) {
handleSwitchToTenant(tenant.id);
}
}}
actions={[
// Primary action - Switch or View Dashboard
{
label: isActive ? 'Ver Dashboard' : 'Cambiar',
icon: isActive ? Eye : ArrowRight,
variant: isActive ? 'outline' : 'primary',
priority: 'primary',
onClick: () => {
if (isActive) {
navigate('/app/dashboard');
} else {
handleSwitchToTenant(tenant.id);
}
}
},
// Settings action
{
label: 'Configuración',
icon: Settings,
priority: 'secondary',
onClick: () => {
if (!isActive) {
handleSwitchToTenant(tenant.id);
}
handleManageTenant();
}
},
// Team management - only for owners
...(isOwnerRole ? [{
label: 'Equipo',
icon: Users,
priority: 'secondary' as const,
highlighted: true,
onClick: () => {
if (!isActive) {
handleSwitchToTenant(tenant.id);
}
handleManageTeam();
}
}] : [])
]}
/>
);
})}
</div>
) : (
<EmptyState
icon={Building2}
title="No se encontraron organizaciones"
description={searchTerm || roleFilter
? "Intenta ajustar los filtros de búsqueda"
: "Crea tu primera organización para comenzar a usar Bakery IA"
}
actionLabel="Nueva Organización"
actionIcon={Plus}
onAction={handleAddNewOrganization}
/>
)}
</div>
);
};

View File

@@ -86,80 +86,20 @@ const SubscriptionPage: React.FC = () => {
subscriptionService.fetchAvailablePlans()
]);
// FIX: Handle demo mode or missing subscription data
// CRITICAL: No more mock data - show real errors instead
if (!usage || !usage.usage) {
// If no usage data, likely a demo tenant - create mock data
const mockUsage: UsageSummary = {
plan: 'starter',
status: 'active',
billing_cycle: 'monthly',
monthly_price: 0,
next_billing_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
usage: {
users: {
current: 1,
limit: 5,
unlimited: false,
usage_percentage: 20
},
locations: {
current: 1,
limit: 1,
unlimited: false,
usage_percentage: 100
},
products: {
current: 0,
limit: 50,
unlimited: false,
usage_percentage: 0
},
recipes: {
current: 0,
limit: 50,
unlimited: false,
usage_percentage: 0
},
suppliers: {
current: 0,
limit: 20,
unlimited: false,
usage_percentage: 0
},
training_jobs_today: {
current: 0,
limit: 1,
unlimited: false,
usage_percentage: 0
},
forecasts_today: {
current: 0,
limit: 10,
unlimited: false,
usage_percentage: 0
},
api_calls_this_hour: {
current: 0,
limit: 100,
unlimited: false,
usage_percentage: 0
},
file_storage_used_gb: {
current: 0,
limit: 5,
unlimited: false,
usage_percentage: 0
}
}
};
setUsageSummary(mockUsage);
} else {
setUsageSummary(usage);
throw new Error('No subscription found. Please contact support or create a new subscription.');
}
setUsageSummary(usage);
setAvailablePlans(plans);
} catch (error) {
console.error('Error loading subscription data:', error);
showToast.error("No se pudo cargar la información de suscripción");
showToast.error(
error instanceof Error && error.message.includes('No subscription')
? error.message
: "No se pudo cargar la información de suscripción. Por favor, contacte con soporte."
);
} finally {
setSubscriptionLoading(false);
}

View File

@@ -79,7 +79,7 @@ const DemoPage = () => {
// Helper function to calculate estimated progress based on elapsed time
const calculateEstimatedProgress = (tier: string, startTime: number): number => {
const elapsed = Date.now() - startTime;
const duration = tier === 'enterprise' ? 75000 : 30000; // ms
const duration = tier === 'enterprise' ? 90000 : 40000; // ms (90s for enterprise, 40s for professional)
const linearProgress = Math.min(95, (elapsed / duration) * 100);
// Logarithmic curve for natural feel - starts fast, slows down
return Math.min(95, Math.round(linearProgress * (1 - Math.exp(-elapsed / 10000))));
@@ -150,17 +150,16 @@ const DemoPage = () => {
const getLoadingMessage = (tier, progress) => {
if (tier === 'enterprise') {
if (progress < 15) return 'Preparando entorno enterprise...';
if (progress < 35) return 'Creando obrador central en Madrid...';
if (progress < 55) return 'Configurando outlets en Barcelona, Valencia y Bilbao...';
if (progress < 75) return 'Generando rutas de distribución optimizadas...';
if (progress < 90) return 'Configurando red de distribución...';
return 'Finalizando configuración enterprise...';
if (progress < 20) return 'Iniciando tu demostración...';
if (progress < 50) return 'Creando tu panadería central...';
if (progress < 80) return 'Configurando tus sucursales...';
if (progress < 95) return 'Preparando datos finales...';
return 'Casi listo...';
} else {
if (progress < 30) return 'Preparando tu panadería...';
if (progress < 60) return 'Configurando inventario y recetas...';
if (progress < 85) return 'Generando datos de ventas y producción...';
return 'Finalizando configuración...';
if (progress < 25) return 'Iniciando tu panadería...';
if (progress < 70) return 'Cargando productos y datos...';
if (progress < 95) return 'Preparando datos finales...';
return 'Casi listo...';
}
};
@@ -806,11 +805,8 @@ const DemoPage = () => {
<ModalHeader
title={
<div className="flex items-center gap-3">
<div className="relative">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--color-primary)]/30 border-t-[var(--color-primary)]"></div>
<div className="absolute inset-0 rounded-full bg-[var(--color-primary)]/10 animate-pulse"></div>
</div>
<span className="text-xl font-bold bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--color-primary)]/30 border-t-[var(--color-primary)]"></div>
<span className="text-xl font-bold text-[var(--text-primary)]">
Configurando Tu Demo
</span>
</div>
@@ -819,145 +815,40 @@ const DemoPage = () => {
/>
<ModalBody padding="xl">
<div className="space-y-8">
{/* Overall Progress Section with Enhanced Visual */}
<div className="text-center space-y-4">
<div className="flex justify-between items-baseline mb-3">
<span className="text-sm font-medium text-[var(--text-secondary)]">Progreso Total</span>
<span className="text-3xl font-bold bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
{/* Overall Progress Section */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-lg font-medium text-[var(--text-primary)]">
{getLoadingMessage(creatingTier, cloneProgress.overall)}
</span>
<span className="text-2xl font-bold text-[var(--color-primary)]">
{cloneProgress.overall}%
</span>
</div>
<div className="relative w-full bg-[var(--bg-tertiary)] rounded-full h-4 overflow-hidden shadow-inner">
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-3 overflow-hidden">
<div
className="relative bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-primary-light)] to-[var(--color-secondary)] h-4 rounded-full transition-all duration-500 ease-out shadow-lg"
className="bg-[var(--color-primary)] h-3 rounded-full transition-all duration-500"
style={{ width: `${cloneProgress.overall}%` }}
>
{/* Shimmer Effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent animate-shimmer"></div>
{/* Glow Effect */}
<div className="absolute inset-0 rounded-full shadow-[0_0_20px_rgba(217,119,6,0.5)]"></div>
</div>
/>
</div>
{estimatedRemainingSeconds !== null && estimatedRemainingSeconds > 0 && (
<div className="flex items-center justify-center gap-2 mt-4">
<Clock className="w-4 h-4 text-[var(--text-tertiary)]" />
<span className="text-sm text-[var(--text-secondary)]">
Aproximadamente <span className="font-semibold text-[var(--color-primary)]">{estimatedRemainingSeconds}s</span> restantes
</span>
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<Clock className="w-4 h-4" />
<span>Aproximadamente {estimatedRemainingSeconds}s restantes</span>
</div>
)}
<p className="text-base text-[var(--text-secondary)] font-medium mt-4">
{getLoadingMessage(creatingTier, cloneProgress.overall)}
</p>
</div>
{/* Enterprise Detailed Progress with Enhanced Visuals */}
{creatingTier === 'enterprise' && (
<div className="space-y-5 mt-8">
{/* Parent Tenant */}
<div className="relative overflow-hidden rounded-2xl p-5 bg-gradient-to-br from-[var(--color-info)]/10 via-[var(--color-info)]/5 to-transparent dark:from-[var(--color-info)]/20 dark:via-[var(--color-info)]/10 border border-[var(--color-info)]/30 shadow-lg">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] shadow-lg animate-pulse"></div>
<span className="font-bold text-[var(--color-info-dark)] dark:text-[var(--color-info-light)] text-lg">
Obrador Central
</span>
</div>
<span className="font-bold text-xl text-[var(--color-info)] dark:text-[var(--color-info-light)]">
{cloneProgress.parent}%
</span>
</div>
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-3 overflow-hidden shadow-inner">
<div
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] h-3 rounded-full transition-all duration-500 shadow-lg relative overflow-hidden"
style={{ width: `${cloneProgress.parent}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer"></div>
</div>
</div>
</div>
{/* Child Outlets with Grid Layout */}
<div className="grid grid-cols-3 gap-4">
{['Barcelona', 'Valencia', 'Bilbao'].map((city, index) => (
<div
key={index}
className="relative overflow-hidden rounded-xl p-4 bg-gradient-to-br from-[var(--color-success)]/10 via-[var(--color-success)]/5 to-transparent dark:from-[var(--color-success)]/20 dark:via-[var(--color-success)]/10 border border-[var(--color-success)]/30 shadow-md hover:shadow-lg transition-shadow"
>
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-[var(--color-success-dark)] dark:text-[var(--color-success-light)] uppercase tracking-wide">
{city}
</span>
<span className="text-sm font-bold text-[var(--color-success-dark)] dark:text-[var(--color-success-light)]">
{cloneProgress.children[index]}%
</span>
</div>
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2.5 overflow-hidden shadow-inner">
<div
className="bg-gradient-to-r from-[var(--color-success)] to-[var(--color-success-dark)] h-2.5 rounded-full transition-all duration-500 relative overflow-hidden"
style={{ width: `${cloneProgress.children[index]}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer"></div>
</div>
</div>
</div>
</div>
))}
</div>
{/* Distribution System */}
<div className="relative overflow-hidden rounded-2xl p-5 bg-gradient-to-br from-[var(--color-secondary)]/10 via-[var(--color-secondary)]/5 to-transparent dark:from-[var(--color-secondary)]/20 dark:via-[var(--color-secondary)]/10 border border-[var(--color-secondary)]/30 shadow-lg">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<Truck className="w-5 h-5 text-[var(--color-secondary)] animate-bounce" />
<span className="font-bold text-[var(--color-secondary-dark)] dark:text-[var(--color-secondary-light)] text-lg">
Sistema de Distribución
</span>
</div>
<span className="font-bold text-xl text-[var(--color-secondary)] dark:text-[var(--color-secondary-light)]">
{cloneProgress.distribution}%
</span>
</div>
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-3 overflow-hidden shadow-inner">
<div
className="bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] h-3 rounded-full transition-all duration-500 shadow-lg relative overflow-hidden"
style={{ width: `${cloneProgress.distribution}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer"></div>
</div>
</div>
</div>
</div>
)}
{/* Professional Progress Indicator */}
{creatingTier === 'professional' && cloneProgress.overall < 100 && (
<div className="text-center py-6">
<div className="flex justify-center items-center gap-2 mb-4">
<div className="w-3 h-3 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-3 h-3 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-3 h-3 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
<p className="text-sm text-[var(--text-tertiary)] font-medium">
Procesando servicios en paralelo...
</p>
</div>
)}
{/* Information Box with Enhanced Design */}
<div className="mt-6 rounded-xl p-4 bg-gradient-to-r from-[var(--bg-secondary)] to-[var(--bg-tertiary)] border border-[var(--border-primary)] shadow-inner">
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-[var(--color-info)] flex-shrink-0 mt-0.5 animate-pulse" />
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">
{creatingTier === 'enterprise'
? 'Creando obrador central, outlets y sistema de distribución con datos reales de ejemplo...'
: 'Personalizando tu panadería con inventario, recetas, y datos de ventas realistas...'}
</p>
</div>
{/* Information Box */}
<div className="rounded-lg p-4 bg-[var(--bg-secondary)] border border-[var(--border-primary)]">
<p className="text-sm text-[var(--text-secondary)]">
{creatingTier === 'enterprise'
? 'Estamos preparando tu panadería con una tienda principal y 3 sucursales conectadas'
: 'Estamos preparando tu panadería con productos, recetas y ventas de ejemplo'}
</p>
</div>
</div>
</ModalBody>

View File

@@ -177,11 +177,24 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
return <>{children}</>;
}
// CRITICAL: If tenant access is still loading (undefined vs null), show loading state
// This prevents showing "unauthorized" during the async load of tenant permissions
const isTenantAccessLoading = currentTenant && currentTenantAccess === undefined;
if (isTenantAccessLoading) {
console.log('⏳ [ProtectedRoute] Waiting for tenant access to load...', {
hasTenant: !!currentTenant,
tenantId: currentTenant?.id,
accessIsUndefined: currentTenantAccess === undefined
});
return fallback || <LoadingSpinner message="Cargando permisos..." />;
}
// Get user roles and permissions
const globalUserRoles = user?.role ? [user.role] : [];
const tenantRole = currentTenantAccess?.role;
const tenantRoles = tenantRole ? [tenantRole] : [];
// Combine global and tenant roles for comprehensive access control
const allUserRoles = [...globalUserRoles, ...tenantRoles];
const tenantPermissions = currentTenantAccess?.permissions || [];
@@ -189,11 +202,28 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
// Check if user can access this route
const canAccess = canAccessRoute(route, isAuthenticated, allUserRoles, tenantPermissions);
console.log('🔐 [ProtectedRoute] Access check:', {
route: route.path,
canAccess,
globalUserRoles,
tenantRoles,
allUserRoles,
tenantPermissions,
currentTenantAccess
});
if (!canAccess) {
// Check if it's a permission issue or role issue
const hasRequiredRoles = !route.requiredRoles ||
const hasRequiredRoles = !route.requiredRoles ||
route.requiredRoles.some(role => allUserRoles.includes(role as string));
console.error('❌ [ProtectedRoute] Access denied:', {
route: route.path,
hasRequiredRoles,
requiredRoles: route.requiredRoles,
userRoles: allUserRoles
});
if (!hasRequiredRoles) {
return <UnauthorizedPage />;
} else {