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 {

View File

@@ -21,6 +21,7 @@ from app.middleware.request_id import RequestIDMiddleware
from app.middleware.auth import AuthMiddleware
from app.middleware.logging import LoggingMiddleware
from app.middleware.rate_limit import RateLimitMiddleware
from app.middleware.rate_limiting import APIRateLimitMiddleware
from app.middleware.subscription import SubscriptionMiddleware
from app.middleware.demo_middleware import DemoMiddleware
from app.middleware.read_only_mode import ReadOnlyModeMiddleware
@@ -90,9 +91,10 @@ app.add_middleware(
)
# Custom middleware - Add in REVERSE order (last added = first executed)
# Execution order: RequestIDMiddleware -> DemoMiddleware -> AuthMiddleware -> ReadOnlyModeMiddleware -> SubscriptionMiddleware -> RateLimitMiddleware -> LoggingMiddleware
app.add_middleware(LoggingMiddleware) # Executes 7th (outermost)
app.add_middleware(RateLimitMiddleware, calls_per_minute=300) # Executes 6th
# Execution order: RequestIDMiddleware -> DemoMiddleware -> AuthMiddleware -> ReadOnlyModeMiddleware -> SubscriptionMiddleware -> APIRateLimitMiddleware -> RateLimitMiddleware -> LoggingMiddleware
app.add_middleware(LoggingMiddleware) # Executes 8th (outermost)
app.add_middleware(RateLimitMiddleware, calls_per_minute=300) # Executes 7th - Simple rate limit
# Note: APIRateLimitMiddleware will be added on startup with Redis client
app.add_middleware(SubscriptionMiddleware, tenant_service_url=settings.TENANT_SERVICE_URL) # Executes 5th
app.add_middleware(ReadOnlyModeMiddleware, tenant_service_url=settings.TENANT_SERVICE_URL) # Executes 4th - Enforce read-only mode
app.add_middleware(AuthMiddleware) # Executes 3rd - Checks for demo context
@@ -123,8 +125,13 @@ async def startup_event():
await initialize_redis(settings.REDIS_URL, db=0, max_connections=50)
redis_client = await get_redis_client()
logger.info("Connected to Redis for SSE streaming")
# Add API rate limiting middleware with Redis client
app.add_middleware(APIRateLimitMiddleware, redis_client=redis_client)
logger.info("API rate limiting middleware enabled with subscription-based quotas")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
logger.warning("API rate limiting middleware will fail open (allow all requests)")
metrics_collector.register_counter(
"gateway_auth_requests_total",

View File

@@ -0,0 +1,260 @@
"""
API Rate Limiting Middleware for Gateway
Enforces subscription-based API call quotas per hour
"""
import structlog
import shared.redis_utils
from datetime import datetime, timezone
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Optional
logger = structlog.get_logger()
class APIRateLimitMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce API rate limits based on subscription tier.
Quota limits per hour:
- Starter: 100 calls/hour
- Professional: 1,000 calls/hour
- Enterprise: 10,000 calls/hour
Uses Redis to track API calls with hourly buckets.
"""
def __init__(self, app, redis_client=None):
super().__init__(app)
self.redis_client = redis_client
async def dispatch(self, request: Request, call_next):
"""
Check API rate limit before processing request.
"""
# Skip rate limiting for certain paths
if self._should_skip_rate_limit(request.url.path):
return await call_next(request)
# Extract tenant_id from request
tenant_id = self._extract_tenant_id(request)
if not tenant_id:
# No tenant ID - skip rate limiting for auth/public endpoints
return await call_next(request)
try:
# Get subscription tier
subscription_tier = await self._get_subscription_tier(tenant_id, request)
# Get quota limit for tier
quota_limit = self._get_quota_limit(subscription_tier)
# Check and increment quota
allowed, current_count = await self._check_and_increment_quota(
tenant_id,
quota_limit
)
if not allowed:
logger.warning(
"API rate limit exceeded",
tenant_id=tenant_id,
subscription_tier=subscription_tier,
current_count=current_count,
quota_limit=quota_limit,
path=request.url.path
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"error": "rate_limit_exceeded",
"message": f"API rate limit exceeded. Maximum {quota_limit} calls per hour allowed for {subscription_tier} plan.",
"current_count": current_count,
"quota_limit": quota_limit,
"reset_time": self._get_reset_time(),
"upgrade_required": subscription_tier in ['starter', 'professional']
}
)
# Add rate limit headers to response
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(quota_limit)
response.headers["X-RateLimit-Remaining"] = str(max(0, quota_limit - current_count))
response.headers["X-RateLimit-Reset"] = self._get_reset_time()
return response
except HTTPException:
raise
except Exception as e:
logger.error(
"Rate limiting check failed, allowing request",
tenant_id=tenant_id,
error=str(e),
path=request.url.path
)
# Fail open - allow request if rate limiting fails
return await call_next(request)
def _should_skip_rate_limit(self, path: str) -> bool:
"""
Determine if path should skip rate limiting.
"""
skip_paths = [
"/health",
"/metrics",
"/docs",
"/openapi.json",
"/api/v1/auth/",
"/api/v1/plans", # Public pricing info
]
for skip_path in skip_paths:
if path.startswith(skip_path):
return True
return False
def _extract_tenant_id(self, request: Request) -> Optional[str]:
"""
Extract tenant ID from request headers or path.
"""
# Try header first
tenant_id = request.headers.get("x-tenant-id")
if tenant_id:
return tenant_id
# Try to extract from path /api/v1/tenants/{tenant_id}/...
path_parts = request.url.path.split("/")
if "tenants" in path_parts:
try:
tenant_index = path_parts.index("tenants")
if len(path_parts) > tenant_index + 1:
return path_parts[tenant_index + 1]
except (ValueError, IndexError):
pass
return None
async def _get_subscription_tier(self, tenant_id: str, request: Request) -> str:
"""
Get subscription tier from tenant service (with caching).
"""
try:
# Try to get from request state (if subscription middleware already ran)
if hasattr(request.state, "subscription_tier"):
return request.state.subscription_tier
# Call tenant service to get tier
import httpx
from gateway.app.core.config import settings
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/subscriptions/{tenant_id}/tier",
headers={
"x-service": "gateway"
}
)
if response.status_code == 200:
data = response.json()
return data.get("tier", "starter")
except Exception as e:
logger.warning(
"Failed to get subscription tier, defaulting to starter",
tenant_id=tenant_id,
error=str(e)
)
return "starter"
def _get_quota_limit(self, subscription_tier: str) -> int:
"""
Get API calls per hour quota for subscription tier.
"""
quota_map = {
"starter": 100,
"professional": 1000,
"enterprise": 10000,
"demo": 1000, # Same as professional
}
return quota_map.get(subscription_tier.lower(), 100)
async def _check_and_increment_quota(
self,
tenant_id: str,
quota_limit: int
) -> tuple[bool, int]:
"""
Check current quota usage and increment counter.
Returns:
(allowed: bool, current_count: int)
"""
if not self.redis_client:
# No Redis - fail open
return True, 0
try:
# Create hourly bucket key
current_hour = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H")
quota_key = f"quota:hourly:api_calls:{tenant_id}:{current_hour}"
# Get current count
current_count = await self.redis_client.get(quota_key)
current_count = int(current_count) if current_count else 0
# Check if within limit
if current_count >= quota_limit:
return False, current_count
# Increment counter
new_count = await self.redis_client.incr(quota_key)
# Set expiry (1 hour + 5 minutes buffer)
await self.redis_client.expire(quota_key, 3900)
return True, new_count
except Exception as e:
logger.error(
"Redis quota check failed",
tenant_id=tenant_id,
error=str(e)
)
# Fail open
return True, 0
def _get_reset_time(self) -> str:
"""
Get the reset time for the current hour bucket (top of next hour).
"""
from datetime import timedelta
now = datetime.now(timezone.utc)
next_hour = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
return next_hour.isoformat()
async def get_rate_limit_middleware(app):
"""
Factory function to create rate limiting middleware with Redis client.
"""
try:
from gateway.app.core.config import settings
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
logger.info("API rate limiting middleware initialized with Redis")
return APIRateLimitMiddleware(app, redis_client=redis_client)
except Exception as e:
logger.warning(
"Failed to initialize Redis for rate limiting, middleware will fail open",
error=str(e)
)
return APIRateLimitMiddleware(app, redis_client=None)

View File

@@ -48,6 +48,12 @@ async def get_tenant_children(request: Request, tenant_id: str = Path(...)):
"""Get tenant children"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/children")
@router.api_route("/bulk-children", methods=["POST", "OPTIONS"])
async def proxy_bulk_children(request: Request):
"""Proxy bulk children creation requests to tenant service"""
return await _proxy_to_tenant_service(request, "/api/v1/tenants/bulk-children")
@router.api_route("/{tenant_id}/children/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_children(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant children requests to tenant service"""

View File

@@ -210,12 +210,11 @@ CREATE TABLE user_onboarding_summary (
8. `product-categorization` - Advanced categorization (optional)
9. `suppliers-setup` - Suppliers configuration
10. `recipes-setup` - Production recipes (optional)
11. `production-processes` - Finishing processes (optional)
12. `quality-setup` - Quality standards (optional)
13. `team-setup` - Team members (optional)
14. `ml-training` - AI model training (requires POI detection)
15. `setup-review` - Review all configuration
16. `completion` - Onboarding completed
11. `quality-setup` - Quality standards (optional)
12. `team-setup` - Team members (optional)
13. `ml-training` - AI model training (requires POI detection)
14. `setup-review` - Review all configuration
15. `completion` - Onboarding completed
**login_attempts**
```sql

View File

@@ -44,12 +44,15 @@ ONBOARDING_STEPS = [
"user_registered", # Auto-completed: User account created
# Phase 1: Discovery
"bakery-type-selection", # Choose bakery type: production/retail/mixed
"bakery-type-selection", # Choose bakery type: production/retail/mixed (skipped for enterprise)
# Phase 2: Core Setup
"setup", # Basic bakery setup and tenant creation
# NOTE: POI detection now happens automatically in background during tenant registration
# Phase 2-Enterprise: Child Tenants Setup (enterprise tier only)
"child-tenants-setup", # Configure child tenants/branches for enterprise tier
# Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
"upload-sales-data", # File upload, validation, and AI classification
"inventory-review", # Review and confirm AI-detected products with type selection
@@ -63,7 +66,6 @@ ONBOARDING_STEPS = [
# Phase 3: Advanced Configuration (all optional)
"recipes-setup", # Production recipes (conditional: production/mixed bakery)
"production-processes", # Finishing processes (conditional: retail/mixed bakery)
"quality-setup", # Quality standards and templates
"team-setup", # Team members and permissions
@@ -79,10 +81,14 @@ STEP_DEPENDENCIES = {
# Discovery phase
"bakery-type-selection": ["user_registered"],
# Core setup - no longer depends on data-source-choice (removed)
# Core setup - NOTE: bakery-type-selection dependency is conditionally required
# Enterprise users skip bakery-type-selection, so setup only requires user_registered for them
"setup": ["user_registered", "bakery-type-selection"],
# NOTE: POI detection removed from steps - now happens automatically in background
# Enterprise child tenants setup - requires setup (parent tenant) to be completed first
"child-tenants-setup": ["user_registered", "setup"],
# AI-Assisted Inventory Setup - REFACTORED into 3 sequential steps
"upload-sales-data": ["user_registered", "setup"],
"inventory-review": ["user_registered", "setup", "upload-sales-data"],
@@ -96,7 +102,6 @@ STEP_DEPENDENCIES = {
# Advanced configuration (optional, minimal dependencies)
"recipes-setup": ["user_registered", "setup"],
"production-processes": ["user_registered", "setup"],
"quality-setup": ["user_registered", "setup"],
"team-setup": ["user_registered", "setup"],
@@ -270,20 +275,41 @@ class OnboardingService:
async def _can_complete_step(self, user_id: str, step_name: str) -> bool:
"""Check if user can complete a specific step"""
# Get required dependencies for this step
required_steps = STEP_DEPENDENCIES.get(step_name, [])
required_steps = STEP_DEPENDENCIES.get(step_name, []).copy() # Copy to avoid modifying original
if not required_steps:
return True # No dependencies
# Check if all required steps are completed
user_progress_data = await self._get_user_onboarding_data(user_id)
# SPECIAL HANDLING FOR ENTERPRISE ONBOARDING
# Enterprise users skip bakery-type-selection step, so don't require it for setup
if step_name == "setup" and "bakery-type-selection" in required_steps:
# Check if user's tenant has enterprise subscription tier
# We do this by checking if the user has any data indicating enterprise tier
# This could be stored in user_registered step data or we can infer from context
user_registered_data = user_progress_data.get("user_registered", {}).get("data", {})
subscription_tier = user_registered_data.get("subscription_tier")
if subscription_tier == "enterprise":
# Enterprise users don't need bakery-type-selection
logger.info(f"Enterprise user {user_id}: Skipping bakery-type-selection requirement for setup step")
required_steps.remove("bakery-type-selection")
elif not user_progress_data.get("bakery-type-selection", {}).get("completed", False):
# Non-enterprise user hasn't completed bakery-type-selection
# But allow setup anyway if user_registered is complete (frontend will handle it)
# This is a fallback for when subscription_tier is not stored in user_registered data
logger.info(f"User {user_id}: Allowing setup without bakery-type-selection (will be auto-set for enterprise)")
required_steps.remove("bakery-type-selection")
for required_step in required_steps:
if not user_progress_data.get(required_step, {}).get("completed", False):
logger.debug(f"Step {step_name} blocked for user {user_id}: missing dependency {required_step}")
return False
# SPECIAL VALIDATION FOR ML TRAINING STEP
if step_name == "ml-training":
# ML training requires AI-assisted path completion

View File

@@ -179,6 +179,7 @@ class EnhancedAuthService:
onboarding_repo = OnboardingRepository(db_session)
plan_data = {
"subscription_plan": user_data.subscription_plan or "starter",
"subscription_tier": user_data.subscription_plan or "starter", # Store tier for enterprise onboarding logic
"use_trial": user_data.use_trial or False,
"payment_method_id": user_data.payment_method_id,
"saved_at": datetime.now(timezone.utc).isoformat()

View File

@@ -8,6 +8,8 @@ from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.ext.asyncio import AsyncSession
import httpx
import structlog
from app.core.database import get_db
from app.services.inventory_service import InventoryService
@@ -25,6 +27,8 @@ from shared.auth.access_control import require_user_role, admin_role_required, o
from shared.routing import RouteBuilder
from shared.security import create_audit_logger, AuditSeverity, AuditAction
logger = structlog.get_logger()
# Create route builder for consistent URL structure
route_builder = RouteBuilder('inventory')
@@ -61,6 +65,58 @@ async def create_ingredient(
):
"""Create a new ingredient (Admin/Manager only)"""
try:
# CRITICAL: Check subscription limit before creating
from app.core.config import settings
async with httpx.AsyncClient(timeout=5.0) as client:
try:
limit_check_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/subscriptions/{tenant_id}/can-add-product",
headers={
"x-user-id": str(current_user.get('user_id')),
"x-tenant-id": str(tenant_id)
}
)
if limit_check_response.status_code == 200:
limit_check = limit_check_response.json()
if not limit_check.get('can_add', False):
logger.warning(
"Product limit exceeded",
tenant_id=str(tenant_id),
current=limit_check.get('current_count'),
max=limit_check.get('max_allowed'),
reason=limit_check.get('reason')
)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"error": "product_limit_exceeded",
"message": limit_check.get('reason', 'Product limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
else:
logger.warning(
"Failed to check product limit, allowing creation",
tenant_id=str(tenant_id),
status_code=limit_check_response.status_code
)
except httpx.TimeoutException:
logger.warning(
"Timeout checking product limit, allowing creation",
tenant_id=str(tenant_id)
)
except httpx.RequestError as e:
logger.warning(
"Error checking product limit, allowing creation",
tenant_id=str(tenant_id),
error=str(e)
)
# Extract user ID - handle service tokens
raw_user_id = current_user.get('user_id')
if current_user.get('type') == 'service':
@@ -73,13 +129,28 @@ async def create_ingredient(
service = InventoryService()
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
logger.info(
"Ingredient created successfully",
tenant_id=str(tenant_id),
ingredient_id=str(ingredient.id),
ingredient_name=ingredient.name
)
return ingredient
except HTTPException:
raise
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error(
"Failed to create ingredient",
tenant_id=str(tenant_id),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create ingredient"

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from uuid import UUID
import logging
import httpx
from ..core.database import get_db
from ..services.recipe_service import RecipeService
@@ -51,6 +52,46 @@ async def create_recipe(
):
"""Create a new recipe"""
try:
# CRITICAL: Check subscription limit before creating
from ..core.config import settings
async with httpx.AsyncClient(timeout=5.0) as client:
try:
# Check recipe limit (not product limit)
limit_check_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/recipes/can-add",
headers={
"x-user-id": str(current_user.get('user_id')),
"x-tenant-id": str(tenant_id)
}
)
if limit_check_response.status_code == 200:
limit_check = limit_check_response.json()
if not limit_check.get('can_add', False):
logger.warning(
f"Recipe limit exceeded for tenant {tenant_id}: {limit_check.get('reason')}"
)
raise HTTPException(
status_code=402,
detail={
"error": "recipe_limit_exceeded",
"message": limit_check.get('reason', 'Recipe limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
else:
logger.warning(
f"Failed to check recipe limit for tenant {tenant_id}, allowing creation"
)
except httpx.TimeoutException:
logger.warning(f"Timeout checking recipe limit for tenant {tenant_id}, allowing creation")
except httpx.RequestError as e:
logger.warning(f"Error checking recipe limit for tenant {tenant_id}: {e}, allowing creation")
recipe_service = RecipeService(db)
recipe_dict = recipe_data.dict(exclude={"ingredients"})
@@ -67,6 +108,8 @@ async def create_recipe(
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
logger.info(f"Recipe created successfully for tenant {tenant_id}: {result['data'].get('name')}")
return RecipeResponse(**result["data"])
except HTTPException:

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -42,6 +43,51 @@ async def create_supplier(
):
"""Create a new supplier"""
try:
# CRITICAL: Check subscription limit before creating
from app.core.config import settings
async with httpx.AsyncClient(timeout=5.0) as client:
try:
limit_check_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/suppliers/can-add",
headers={
"x-user-id": str(current_user.get('user_id')),
"x-tenant-id": str(tenant_id)
}
)
if limit_check_response.status_code == 200:
limit_check = limit_check_response.json()
if not limit_check.get('can_add', False):
logger.warning(
"Supplier limit exceeded",
tenant_id=tenant_id,
current=limit_check.get('current_count'),
max=limit_check.get('max_allowed'),
reason=limit_check.get('reason')
)
raise HTTPException(
status_code=402,
detail={
"error": "supplier_limit_exceeded",
"message": limit_check.get('reason', 'Supplier limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
else:
logger.warning(
"Failed to check supplier limit, allowing creation",
tenant_id=tenant_id,
status_code=limit_check_response.status_code
)
except httpx.TimeoutException:
logger.warning("Timeout checking supplier limit, allowing creation", tenant_id=tenant_id)
except httpx.RequestError as e:
logger.warning("Error checking supplier limit, allowing creation", tenant_id=tenant_id, error=str(e))
service = SupplierService(db)
# Get user role from current_user dict
@@ -53,7 +99,12 @@ async def create_supplier(
created_by=current_user["user_id"],
created_by_role=user_role
)
logger.info("Supplier created successfully", tenant_id=tenant_id, supplier_id=str(supplier.id), supplier_name=supplier.name)
return SupplierResponse.from_orm(supplier)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:

View File

@@ -193,6 +193,7 @@ async def clone_demo_data(
plan=subscription_data.get('plan', 'professional'),
status=subscription_data.get('status', 'active'),
monthly_price=subscription_data.get('monthly_price', 299.00),
billing_cycle=subscription_data.get('billing_cycle', 'monthly'),
max_users=subscription_data.get('max_users', 10),
max_locations=subscription_data.get('max_locations', 3),
max_products=subscription_data.get('max_products', 500),
@@ -206,6 +207,18 @@ async def clone_demo_data(
subscription_data.get('next_billing_date'),
session_time,
"next_billing_date"
),
stripe_subscription_id=subscription_data.get('stripe_subscription_id'),
stripe_customer_id=subscription_data.get('stripe_customer_id'),
cancelled_at=parse_date_field(
subscription_data.get('cancelled_at'),
session_time,
"cancelled_at"
),
cancellation_effective_date=parse_date_field(
subscription_data.get('cancellation_effective_date'),
session_time,
"cancellation_effective_date"
)
)

View File

@@ -125,6 +125,26 @@ async def cancel_subscription(
await db.commit()
await db.refresh(subscription)
# CRITICAL: Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after cancellation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after cancellation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days
logger.info(
@@ -197,6 +217,26 @@ async def reactivate_subscription(
await db.commit()
await db.refresh(subscription)
# CRITICAL: Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after reactivation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after reactivation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
logger.info(
"subscription_reactivated",
tenant_id=str(tenant_id),

View File

@@ -6,7 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, Path
from typing import List, Dict, Any
from uuid import UUID
from app.schemas.tenants import TenantResponse
from app.schemas.tenants import (
TenantResponse,
ChildTenantCreate,
BulkChildTenantsCreate,
BulkChildTenantsResponse,
ChildTenantResponse
)
from app.services.tenant_service import EnhancedTenantService
from app.repositories.tenant_repository import TenantRepository
from shared.auth.decorators import get_current_user_dep
@@ -213,12 +219,248 @@ async def get_tenant_children_count(
)
@router.post(route_builder.build_base_route("bulk-children", include_tenant_prefix=False), response_model=BulkChildTenantsResponse)
@track_endpoint_metrics("bulk_create_child_tenants")
async def bulk_create_child_tenants(
request: BulkChildTenantsCreate,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""
Bulk create child tenants for enterprise onboarding.
This endpoint creates multiple child tenants (outlets/branches) for an enterprise parent tenant
and establishes the parent-child relationship. It's designed for use during the onboarding flow
when an enterprise customer registers their network of locations.
Features:
- Creates child tenants with proper hierarchy
- Inherits subscription from parent
- Optionally configures distribution routes
- Returns detailed success/failure information
"""
try:
logger.info(
"Bulk child tenant creation request received",
parent_tenant_id=request.parent_tenant_id,
child_count=len(request.child_tenants),
user_id=current_user.get("user_id")
)
# Verify parent tenant exists and user has access
async with tenant_service.database_manager.get_session() as session:
from app.models.tenants import Tenant
tenant_repo = TenantRepository(Tenant, session)
parent_tenant = await tenant_repo.get_by_id(request.parent_tenant_id)
if not parent_tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent tenant not found"
)
# Verify user has access to parent tenant (owners/admins only)
access_info = await tenant_service.verify_user_access(
current_user["user_id"],
request.parent_tenant_id
)
if not access_info.has_access or access_info.role not in ["owner", "admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only tenant owners/admins can create child tenants"
)
# Verify parent is enterprise tier
parent_subscription_tier = await tenant_service.get_subscription_tier(request.parent_tenant_id)
if parent_subscription_tier != "enterprise":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only enterprise tier tenants can have child tenants"
)
# Update parent tenant type if it's still standalone
if parent_tenant.tenant_type == "standalone":
parent_tenant.tenant_type = "parent"
parent_tenant.hierarchy_path = str(parent_tenant.id)
await session.commit()
await session.refresh(parent_tenant)
# Create child tenants
created_tenants = []
failed_tenants = []
for child_data in request.child_tenants:
try:
# Create child tenant
child_tenant = Tenant(
name=child_data.name,
subdomain=None, # Child tenants typically don't have subdomains
business_type=parent_tenant.business_type,
business_model="retail_bakery", # Child outlets are typically retail
address=child_data.address,
city=child_data.city,
postal_code=child_data.postal_code,
latitude=child_data.latitude,
longitude=child_data.longitude,
phone=child_data.phone or parent_tenant.phone,
email=child_data.email or parent_tenant.email,
timezone=parent_tenant.timezone,
owner_id=parent_tenant.owner_id,
parent_tenant_id=parent_tenant.id,
tenant_type="child",
hierarchy_path=f"{parent_tenant.hierarchy_path}/{str(parent_tenant.id)}",
is_active=True,
is_demo=parent_tenant.is_demo,
demo_session_id=parent_tenant.demo_session_id,
demo_expires_at=parent_tenant.demo_expires_at
)
session.add(child_tenant)
await session.flush() # Get the ID without committing
# Create TenantLocation record for the child with location_code
from app.models.tenant_location import TenantLocation
location = TenantLocation(
tenant_id=child_tenant.id,
name=child_data.name,
location_code=child_data.location_code,
city=child_data.city,
zone=child_data.zone,
address=child_data.address,
postal_code=child_data.postal_code,
latitude=child_data.latitude,
longitude=child_data.longitude,
status="ACTIVE",
is_primary=True,
enterprise_location=True,
location_type="retail"
)
session.add(location)
# Inherit subscription from parent
from app.models.tenants import Subscription
parent_subscription = await session.execute(
session.query(Subscription).filter(
Subscription.tenant_id == parent_tenant.id,
Subscription.status == "active"
).statement
)
parent_sub = parent_subscription.scalar_one_or_none()
if parent_sub:
child_subscription = Subscription(
tenant_id=child_tenant.id,
plan=parent_sub.plan,
status="active",
billing_cycle=parent_sub.billing_cycle,
price=0, # Child tenants don't pay separately
trial_ends_at=parent_sub.trial_ends_at
)
session.add(child_subscription)
await session.commit()
await session.refresh(child_tenant)
await session.refresh(location)
# Build response
created_tenants.append(ChildTenantResponse(
id=str(child_tenant.id),
name=child_tenant.name,
subdomain=child_tenant.subdomain,
business_type=child_tenant.business_type,
business_model=child_tenant.business_model,
tenant_type=child_tenant.tenant_type,
parent_tenant_id=str(child_tenant.parent_tenant_id),
address=child_tenant.address,
city=child_tenant.city,
postal_code=child_tenant.postal_code,
phone=child_tenant.phone,
is_active=child_tenant.is_active,
subscription_plan="enterprise",
ml_model_trained=child_tenant.ml_model_trained,
last_training_date=child_tenant.last_training_date,
owner_id=str(child_tenant.owner_id),
created_at=child_tenant.created_at,
location_code=location.location_code,
zone=location.zone,
hierarchy_path=child_tenant.hierarchy_path
))
logger.info(
"Child tenant created successfully",
child_tenant_id=str(child_tenant.id),
child_name=child_tenant.name,
location_code=child_data.location_code
)
except Exception as child_error:
logger.error(
"Failed to create child tenant",
child_name=child_data.name,
error=str(child_error)
)
failed_tenants.append({
"name": child_data.name,
"location_code": child_data.location_code,
"error": str(child_error)
})
await session.rollback()
# TODO: Configure distribution routes if requested
distribution_configured = False
if request.auto_configure_distribution and len(created_tenants) > 0:
try:
# This would call the distribution service to set up routes
# For now, we'll skip this and just log
logger.info(
"Distribution route configuration requested",
parent_tenant_id=request.parent_tenant_id,
child_count=len(created_tenants)
)
# distribution_configured = await configure_distribution_routes(...)
except Exception as dist_error:
logger.warning(
"Failed to configure distribution routes",
error=str(dist_error)
)
logger.info(
"Bulk child tenant creation completed",
parent_tenant_id=request.parent_tenant_id,
created_count=len(created_tenants),
failed_count=len(failed_tenants)
)
return BulkChildTenantsResponse(
parent_tenant_id=request.parent_tenant_id,
created_count=len(created_tenants),
failed_count=len(failed_tenants),
created_tenants=created_tenants,
failed_tenants=failed_tenants,
distribution_configured=distribution_configured
)
except HTTPException:
raise
except Exception as e:
logger.error(
"Bulk child tenant creation failed",
parent_tenant_id=request.parent_tenant_id,
user_id=current_user.get("user_id"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Bulk child tenant creation failed: {str(e)}"
)
# Register the router in the main app
def register_hierarchy_routes(app):
"""Register hierarchy routes with the main application"""
from shared.routing.route_builder import RouteBuilder
route_builder = RouteBuilder("tenants")
# Include the hierarchy routes with proper tenant prefix
app.include_router(
router,

View File

@@ -48,6 +48,31 @@ async def add_team_member_with_user_creation(
In production, this will be replaced with an invitation-based flow.
"""
try:
# CRITICAL: Check subscription limit before adding user
from app.services.subscription_limit_service import SubscriptionLimitService
limit_service = SubscriptionLimitService()
limit_check = await limit_service.can_add_user(str(tenant_id))
if not limit_check.get('can_add', False):
logger.warning(
"User limit exceeded",
tenant_id=str(tenant_id),
current=limit_check.get('current_count'),
max=limit_check.get('max_allowed'),
reason=limit_check.get('reason')
)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"error": "user_limit_exceeded",
"message": limit_check.get('reason', 'User limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
user_id_to_add = member_data.user_id
# If create_user is True, create the user first via auth service
@@ -151,12 +176,45 @@ async def add_team_member(
"""Add an existing team member to tenant (legacy endpoint)"""
try:
# CRITICAL: Check subscription limit before adding user
from app.services.subscription_limit_service import SubscriptionLimitService
limit_service = SubscriptionLimitService()
limit_check = await limit_service.can_add_user(str(tenant_id))
if not limit_check.get('can_add', False):
logger.warning(
"User limit exceeded",
tenant_id=str(tenant_id),
current=limit_check.get('current_count'),
max=limit_check.get('max_allowed'),
reason=limit_check.get('reason')
)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"error": "user_limit_exceeded",
"message": limit_check.get('reason', 'User limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
result = await tenant_service.add_team_member(
str(tenant_id),
user_id,
role,
current_user["user_id"]
)
logger.info(
"Team member added successfully",
tenant_id=str(tenant_id),
user_id=user_id,
role=role
)
return result
except HTTPException:

View File

@@ -141,6 +141,44 @@ async def register_bakery(
current_user["user_id"]
)
# CRITICAL: Create default subscription for new tenant
try:
from app.repositories.subscription_repository import SubscriptionRepository
from app.models.tenants import Subscription
from datetime import datetime, timedelta, timezone
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
async with database_manager.get_session() as session:
subscription_repo = SubscriptionRepository(Subscription, session)
# Create starter subscription with 14-day trial
trial_end_date = datetime.now(timezone.utc) + timedelta(days=14)
next_billing_date = trial_end_date
await subscription_repo.create_subscription(
tenant_id=str(result.id),
plan="starter",
status="active",
billing_cycle="monthly",
next_billing_date=next_billing_date,
trial_ends_at=trial_end_date
)
await session.commit()
logger.info(
"Default subscription created for new tenant",
tenant_id=str(result.id),
plan="starter",
trial_days=14
)
except Exception as subscription_error:
logger.error(
"Failed to create default subscription for tenant",
tenant_id=str(result.id),
error=str(subscription_error)
)
# Don't fail tenant creation if subscription creation fails
# If coupon was validated, redeem it now with actual tenant_id
if coupon_validation and coupon_validation["valid"]:
from app.core.config import settings
@@ -785,6 +823,48 @@ async def can_add_user(
detail="Failed to check user limits"
)
@router.get(route_builder.build_base_route("{tenant_id}/recipes/can-add", include_tenant_prefix=False))
async def can_add_recipe(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
):
"""Check if tenant can add another recipe"""
try:
result = await limit_service.can_add_recipe(str(tenant_id))
return result
except Exception as e:
logger.error("Failed to check recipe limits",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to check recipe limits"
)
@router.get(route_builder.build_base_route("{tenant_id}/suppliers/can-add", include_tenant_prefix=False))
async def can_add_supplier(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
):
"""Check if tenant can add another supplier"""
try:
result = await limit_service.can_add_supplier(str(tenant_id))
return result
except Exception as e:
logger.error("Failed to check supplier limits",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to check supplier limits"
)
@router.get(route_builder.build_base_route("subscriptions/{tenant_id}/features/{feature}", include_tenant_prefix=False))
async def has_feature(
tenant_id: UUID = Path(..., description="Tenant ID"),

View File

@@ -197,8 +197,102 @@ class TenantStatsResponse(BaseModel):
last_training_date: Optional[datetime]
subscription_plan: str
subscription_status: str
@field_validator('tenant_id', mode='before')
# ============================================================================
# ENTERPRISE CHILD TENANT SCHEMAS
# ============================================================================
class ChildTenantCreate(BaseModel):
"""Schema for creating a child tenant in enterprise hierarchy"""
name: str = Field(..., min_length=2, max_length=200, description="Child tenant name (e.g., 'Madrid - Salamanca')")
city: str = Field(..., min_length=2, max_length=100, description="City where the outlet is located")
zone: Optional[str] = Field(None, max_length=100, description="Zone or neighborhood")
address: str = Field(..., min_length=10, max_length=500, description="Full address of the outlet")
postal_code: str = Field(..., pattern=r"^\d{5}$", description="5-digit postal code")
location_code: str = Field(..., min_length=1, max_length=10, description="Short location code (e.g., MAD, BCN)")
# Optional coordinates (can be geocoded from address if not provided)
latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate")
longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate")
# Optional contact info (inherits from parent if not provided)
phone: Optional[str] = Field(None, min_length=9, max_length=20, description="Contact phone")
email: Optional[str] = Field(None, description="Contact email")
@field_validator('location_code')
@classmethod
def validate_location_code(cls, v):
"""Ensure location code is uppercase and alphanumeric"""
if not v.replace('-', '').replace('_', '').isalnum():
raise ValueError('Location code must be alphanumeric (with optional hyphens/underscores)')
return v.upper()
@field_validator('phone')
@classmethod
def validate_phone(cls, v):
"""Validate Spanish phone number if provided"""
if v is None:
return v
phone = re.sub(r'[\s\-\(\)]', '', v)
patterns = [
r'^(\+34|0034|34)?[6789]\d{8}$', # Mobile
r'^(\+34|0034|34)?9\d{8}$', # Landline
]
if not any(re.match(pattern, phone) for pattern in patterns):
raise ValueError('Invalid Spanish phone number')
return v
class BulkChildTenantsCreate(BaseModel):
"""Schema for bulk creating child tenants during onboarding"""
parent_tenant_id: str = Field(..., description="ID of the parent (central baker) tenant")
child_tenants: List[ChildTenantCreate] = Field(
...,
min_length=1,
max_length=50,
description="List of child tenants to create (1-50)"
)
# Optional: Auto-configure distribution routes
auto_configure_distribution: bool = Field(
True,
description="Whether to automatically set up distribution routes between parent and children"
)
@field_validator('child_tenants')
@classmethod
def validate_unique_location_codes(cls, v):
"""Ensure all location codes are unique within the batch"""
location_codes = [ct.location_code for ct in v]
if len(location_codes) != len(set(location_codes)):
raise ValueError('Location codes must be unique within the batch')
return v
class ChildTenantResponse(TenantResponse):
"""Response schema for child tenant - extends TenantResponse"""
location_code: Optional[str] = None
zone: Optional[str] = None
hierarchy_path: Optional[str] = None
class Config:
from_attributes = True
class BulkChildTenantsResponse(BaseModel):
"""Response schema for bulk child tenant creation"""
parent_tenant_id: str
created_count: int
failed_count: int
created_tenants: List[ChildTenantResponse]
failed_tenants: List[Dict[str, Any]] = Field(
default_factory=list,
description="List of failed tenants with error details"
)
distribution_configured: bool = False
@field_validator('parent_tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""

View File

@@ -155,20 +155,20 @@ class SubscriptionLimitService:
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
# Get subscription limits
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
if not subscription:
return {"can_add": False, "reason": "No active subscription"}
# Check if unlimited users (-1)
if subscription.max_users == -1:
return {"can_add": True, "reason": "Unlimited users allowed"}
# Count current active members
members = await self.member_repo.get_tenant_members(tenant_id, active_only=True)
current_users = len(members)
can_add = current_users < subscription.max_users
return {
"can_add": can_add,
@@ -176,12 +176,80 @@ class SubscriptionLimitService:
"max_allowed": subscription.max_users,
"reason": "Within limits" if can_add else f"Maximum {subscription.max_users} users allowed for {subscription.plan} plan"
}
except Exception as e:
logger.error("Failed to check user limits",
tenant_id=tenant_id,
error=str(e))
return {"can_add": False, "reason": "Error checking limits"}
async def can_add_recipe(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another recipe"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
if not subscription:
return {"can_add": False, "reason": "No active subscription"}
# Get recipe limit from plan
recipes_limit = await self._get_limit_from_plan(subscription.plan, 'recipes')
# Check if unlimited (-1 or None)
if recipes_limit is None or recipes_limit == -1:
return {"can_add": True, "reason": "Unlimited recipes allowed"}
# Count current recipes from recipes service
current_recipes = await self._get_recipe_count(tenant_id)
can_add = current_recipes < recipes_limit
return {
"can_add": can_add,
"current_count": current_recipes,
"max_allowed": recipes_limit,
"reason": "Within limits" if can_add else f"Maximum {recipes_limit} recipes allowed for {subscription.plan} plan"
}
except Exception as e:
logger.error("Failed to check recipe limits",
tenant_id=tenant_id,
error=str(e))
return {"can_add": False, "reason": "Error checking limits"}
async def can_add_supplier(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another supplier"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
if not subscription:
return {"can_add": False, "reason": "No active subscription"}
# Get supplier limit from plan
suppliers_limit = await self._get_limit_from_plan(subscription.plan, 'suppliers')
# Check if unlimited (-1 or None)
if suppliers_limit is None or suppliers_limit == -1:
return {"can_add": True, "reason": "Unlimited suppliers allowed"}
# Count current suppliers from suppliers service
current_suppliers = await self._get_supplier_count(tenant_id)
can_add = current_suppliers < suppliers_limit
return {
"can_add": can_add,
"current_count": current_suppliers,
"max_allowed": suppliers_limit,
"reason": "Within limits" if can_add else f"Maximum {suppliers_limit} suppliers allowed for {subscription.plan} plan"
}
except Exception as e:
logger.error("Failed to check supplier limits",
tenant_id=tenant_id,
error=str(e))
return {"can_add": False, "reason": "Error checking limits"}
async def has_feature(self, tenant_id: str, feature: str) -> Dict[str, Any]:
"""Check if tenant has access to a specific feature"""

View File

@@ -175,7 +175,7 @@ def upgrade() -> None:
sa.UniqueConstraint('tenant_id')
)
# Create subscriptions table with all current columns
# Create subscriptions table with all quota columns
op.create_table('subscriptions',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -189,9 +189,33 @@ def upgrade() -> None:
sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
# Basic resource limits
sa.Column('max_users', sa.Integer(), nullable=True),
sa.Column('max_locations', sa.Integer(), nullable=True),
sa.Column('max_products', sa.Integer(), nullable=True),
sa.Column('max_recipes', sa.Integer(), nullable=True),
sa.Column('max_suppliers', sa.Integer(), nullable=True),
# Daily/hourly quota limits
sa.Column('training_jobs_per_day', sa.Integer(), nullable=True),
sa.Column('forecast_generation_per_day', sa.Integer(), nullable=True),
sa.Column('api_calls_per_hour', sa.Integer(), nullable=True),
# Storage limits
sa.Column('file_storage_gb', sa.Integer(), nullable=True),
# Data access limits
sa.Column('dataset_size_rows', sa.Integer(), nullable=True),
sa.Column('forecast_horizon_days', sa.Integer(), nullable=True),
sa.Column('historical_data_access_days', sa.Integer(), nullable=True),
# Bulk operation limits
sa.Column('bulk_import_rows', sa.Integer(), nullable=True),
sa.Column('bulk_export_rows', sa.Integer(), nullable=True),
# Integration limits
sa.Column('webhook_endpoints', sa.Integer(), nullable=True),
sa.Column('pos_sync_interval_minutes', sa.Integer(), nullable=True),
# Reporting limits
sa.Column('report_retention_days', sa.Integer(), nullable=True),
# Enterprise-specific limits
sa.Column('max_child_tenants', sa.Integer(), nullable=True),
# Features and metadata
sa.Column('features', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),

View File

@@ -23,6 +23,7 @@
"plan": "enterprise",
"status": "active",
"monthly_price": 1999.0,
"billing_cycle": "monthly",
"max_users": 50,
"max_locations": 20,
"max_products": 5000,
@@ -38,22 +39,14 @@
"cross_location_optimization": true,
"distribution_management": true
},
"trial_ends_at": "2025-02-15T06:00:00Z",
"next_billing_date": "2025-02-01T06:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"enterprise_features": [
"multi_location_management",
"centralized_inventory",
"centralized_production",
"bulk_procurement",
"advanced_analytics",
"custom_reporting",
"api_access",
"priority_support",
"cross_location_optimization",
"distribution_management"
]
"trial_ends_at": "BASE_TS+60d",
"next_billing_date": "BASE_TS+30d",
"stripe_subscription_id": null,
"stripe_customer_id": null,
"cancelled_at": null,
"cancellation_effective_date": null,
"created_at": "BASE_TS-90d",
"updated_at": "BASE_TS-1d"
},
"children": [
{

View File

@@ -23,6 +23,7 @@
"plan": "professional",
"status": "active",
"monthly_price": 299.00,
"billing_cycle": "monthly",
"max_users": 10,
"max_locations": 3,
"max_products": 500,
@@ -36,9 +37,13 @@
"api_access": true,
"priority_support": true
},
"trial_ends_at": "2025-02-15T06:00:00Z",
"next_billing_date": "2025-02-01T06:00:00Z",
"created_at": "2025-01-15T06:00:00Z",
"updated_at": "2025-01-15T06:00:00Z"
"trial_ends_at": "BASE_TS+30d",
"next_billing_date": "BASE_TS+30d",
"stripe_subscription_id": null,
"stripe_customer_id": null,
"cancelled_at": null,
"cancellation_effective_date": null,
"created_at": "BASE_TS-30d",
"updated_at": "BASE_TS-30d"
}
}