Improve onboarding
This commit is contained in:
@@ -17,7 +17,6 @@ export const BACKEND_ONBOARDING_STEPS = [
|
|||||||
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
||||||
'suppliers-setup', // Phase 2d: Suppliers configuration
|
'suppliers-setup', // Phase 2d: Suppliers configuration
|
||||||
'recipes-setup', // Phase 3: Production recipes (optional)
|
'recipes-setup', // Phase 3: Production recipes (optional)
|
||||||
'production-processes', // Phase 3: Finishing processes (optional)
|
|
||||||
'quality-setup', // Phase 3: Quality standards (optional)
|
'quality-setup', // Phase 3: Quality standards (optional)
|
||||||
'team-setup', // Phase 3: Team members (optional)
|
'team-setup', // Phase 3: Team members (optional)
|
||||||
'ml-training', // Phase 4: AI model training
|
'ml-training', // Phase 4: AI model training
|
||||||
@@ -36,7 +35,6 @@ export const FRONTEND_STEP_ORDER = [
|
|||||||
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
||||||
'suppliers-setup', // Phase 2d: Suppliers configuration
|
'suppliers-setup', // Phase 2d: Suppliers configuration
|
||||||
'recipes-setup', // Phase 3: Production recipes (optional)
|
'recipes-setup', // Phase 3: Production recipes (optional)
|
||||||
'production-processes', // Phase 3: Finishing processes (optional)
|
|
||||||
'quality-setup', // Phase 3: Quality standards (optional)
|
'quality-setup', // Phase 3: Quality standards (optional)
|
||||||
'team-setup', // Phase 3: Team members (optional)
|
'team-setup', // Phase 3: Team members (optional)
|
||||||
'ml-training', // Phase 4: AI model training
|
'ml-training', // Phase 4: AI model training
|
||||||
|
|||||||
@@ -86,6 +86,32 @@ export class TenantService {
|
|||||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/${parentTenantId}/children`);
|
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
|
// OPERATIONS: Search & Discovery
|
||||||
// Backend: services/tenant/app/api/tenant_operations.py
|
// Backend: services/tenant/app/api/tenant_operations.py
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
|
|
||||||
await register(registrationData);
|
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
|
const successMessage = isPilot
|
||||||
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
|
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
|
||||||
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
|
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
|
|||||||
import {
|
import {
|
||||||
BakeryTypeSelectionStep,
|
BakeryTypeSelectionStep,
|
||||||
RegisterTenantStep,
|
RegisterTenantStep,
|
||||||
|
ChildTenantsSetupStep,
|
||||||
FileUploadStep,
|
FileUploadStep,
|
||||||
InventoryReviewStep,
|
InventoryReviewStep,
|
||||||
ProductCategorizationStep,
|
ProductCategorizationStep,
|
||||||
InitialStockEntryStep,
|
InitialStockEntryStep,
|
||||||
ProductionProcessesStep,
|
|
||||||
MLTrainingStep,
|
MLTrainingStep,
|
||||||
CompletionStep
|
CompletionStep
|
||||||
} from './steps';
|
} from './steps';
|
||||||
@@ -55,6 +55,26 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const wizardContext = useWizardContext();
|
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 possible steps with conditional visibility
|
||||||
// All step IDs match backend ONBOARDING_STEPS exactly
|
// All step IDs match backend ONBOARDING_STEPS exactly
|
||||||
const ALL_STEPS: StepConfig[] = [
|
const ALL_STEPS: StepConfig[] = [
|
||||||
@@ -64,15 +84,50 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
title: t('onboarding:steps.bakery_type.title', 'Tipo de Panadería'),
|
title: t('onboarding:steps.bakery_type.title', 'Tipo de Panadería'),
|
||||||
description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'),
|
description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'),
|
||||||
component: BakeryTypeSelectionStep,
|
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
|
// Phase 2: Core Setup
|
||||||
{
|
{
|
||||||
id: 'setup',
|
id: 'setup',
|
||||||
title: t('onboarding:steps.setup.title', 'Registrar Panadería'),
|
// Dynamic title based on subscription tier
|
||||||
description: t('onboarding:steps.setup.description', 'Información básica'),
|
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,
|
component: RegisterTenantStep,
|
||||||
isConditional: true,
|
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
|
// POI Detection removed - now happens automatically in background after tenant registration
|
||||||
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
|
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
|
||||||
@@ -116,15 +171,6 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
condition: (ctx) =>
|
condition: (ctx) =>
|
||||||
ctx.state.bakeryType === 'production' || ctx.state.bakeryType === 'mixed',
|
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)
|
// Phase 3: Advanced Features (Optional)
|
||||||
{
|
{
|
||||||
id: 'quality-setup',
|
id: 'quality-setup',
|
||||||
@@ -171,13 +217,15 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
|
|
||||||
console.log('🔄 VISIBLE_STEPS recalculated:', visibleSteps.map(s => s.id));
|
console.log('🔄 VISIBLE_STEPS recalculated:', visibleSteps.map(s => s.id));
|
||||||
console.log('📊 Wizard state:', {
|
console.log('📊 Wizard state:', {
|
||||||
|
bakeryType: wizardContext.state.bakeryType,
|
||||||
|
subscriptionTier: localStorage.getItem('subscription_tier'),
|
||||||
stockEntryCompleted: wizardContext.state.stockEntryCompleted,
|
stockEntryCompleted: wizardContext.state.stockEntryCompleted,
|
||||||
aiAnalysisComplete: wizardContext.state.aiAnalysisComplete,
|
aiAnalysisComplete: wizardContext.state.aiAnalysisComplete,
|
||||||
categorizationCompleted: wizardContext.state.categorizationCompleted,
|
categorizationCompleted: wizardContext.state.categorizationCompleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
return visibleSteps;
|
return visibleSteps;
|
||||||
}, [wizardContext.state]);
|
}, [wizardContext.state, isEnterprise]); // Added isEnterprise to dependencies
|
||||||
|
|
||||||
const isNewTenant = searchParams.get('new') === 'true';
|
const isNewTenant = searchParams.get('new') === 'true';
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
@@ -192,7 +240,7 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const markStepCompleted = useMarkStepCompleted();
|
const markStepCompleted = useMarkStepCompleted();
|
||||||
const { setCurrentTenant } = useTenantActions();
|
const { setCurrentTenant, loadUserTenants } = useTenantActions();
|
||||||
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
|
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
|
||||||
|
|
||||||
// Auto-complete user_registered step
|
// Auto-complete user_registered step
|
||||||
@@ -335,12 +383,74 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
if (currentStep.id === 'inventory-setup') {
|
if (currentStep.id === 'inventory-setup') {
|
||||||
wizardContext.markStepComplete('inventoryCompleted');
|
wizardContext.markStepComplete('inventoryCompleted');
|
||||||
}
|
}
|
||||||
if (currentStep.id === 'setup' && data?.tenant) {
|
if (currentStep.id === 'setup') {
|
||||||
setCurrentTenant(data.tenant);
|
console.log('✅ Setup step completed with data:', { hasTenant: !!data?.tenant, hasUser: !!user?.id, data });
|
||||||
|
|
||||||
// If tenant info and location are available in data, update the wizard context
|
if (data?.tenant) {
|
||||||
if (data.tenantId && data.bakeryLocation) {
|
setCurrentTenant(data.tenant);
|
||||||
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
|
|
||||||
|
// 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>
|
||||||
<div className="text-center sm:text-right">
|
<div className="text-center sm:text-right">
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
<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,
|
current: currentStepIndex + 1,
|
||||||
total: VISIBLE_STEPS.length
|
total: VISIBLE_STEPS.length
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 {
|
export interface WizardState {
|
||||||
// Discovery Phase
|
// Discovery Phase
|
||||||
bakeryType: BakeryType;
|
bakeryType: BakeryType;
|
||||||
@@ -44,6 +55,10 @@ export interface WizardState {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Enterprise Setup Data
|
||||||
|
childTenants?: ChildTenantData[]; // Child tenant locations for enterprise tier
|
||||||
|
childTenantsCompleted: boolean;
|
||||||
|
|
||||||
// AI-Assisted Path Data
|
// AI-Assisted Path Data
|
||||||
uploadedFile?: File; // NEW: The actual file object needed for sales import API
|
uploadedFile?: File; // NEW: The actual file object needed for sales import API
|
||||||
uploadedFileName?: string;
|
uploadedFileName?: string;
|
||||||
@@ -82,6 +97,7 @@ export interface WizardContextValue {
|
|||||||
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
|
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
|
||||||
updateLocation: (location: { latitude: number; longitude: number }) => void;
|
updateLocation: (location: { latitude: number; longitude: number }) => void;
|
||||||
updateTenantId: (tenantId: string) => void;
|
updateTenantId: (tenantId: string) => void;
|
||||||
|
updateChildTenants: (tenants: ChildTenantData[]) => void; // NEW: Store child tenants
|
||||||
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
|
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
|
||||||
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
|
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
|
||||||
setAIAnalysisComplete: (complete: boolean) => void;
|
setAIAnalysisComplete: (complete: boolean) => void;
|
||||||
@@ -97,6 +113,8 @@ export interface WizardContextValue {
|
|||||||
const initialState: WizardState = {
|
const initialState: WizardState = {
|
||||||
bakeryType: null,
|
bakeryType: null,
|
||||||
dataSource: 'ai-assisted', // Only AI-assisted path supported now
|
dataSource: 'ai-assisted', // Only AI-assisted path supported now
|
||||||
|
childTenants: undefined,
|
||||||
|
childTenantsCompleted: false,
|
||||||
aiSuggestions: [],
|
aiSuggestions: [],
|
||||||
aiAnalysisComplete: false,
|
aiAnalysisComplete: false,
|
||||||
categorizedProducts: undefined,
|
categorizedProducts: undefined,
|
||||||
@@ -176,6 +194,10 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateChildTenants = (tenants: ChildTenantData[]) => {
|
||||||
|
setState(prev => ({ ...prev, childTenants: tenants }));
|
||||||
|
};
|
||||||
|
|
||||||
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
|
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
|
||||||
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
|
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
|
||||||
};
|
};
|
||||||
@@ -251,15 +273,11 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
|||||||
steps.push('suppliers-setup');
|
steps.push('suppliers-setup');
|
||||||
steps.push('inventory-setup');
|
steps.push('inventory-setup');
|
||||||
|
|
||||||
// Conditional: Recipes vs Processes
|
// Conditional: Recipes
|
||||||
if (state.bakeryType === 'production' || state.bakeryType === 'mixed') {
|
if (state.bakeryType === 'production' || state.bakeryType === 'mixed') {
|
||||||
steps.push('recipes-setup');
|
steps.push('recipes-setup');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.bakeryType === 'retail' || state.bakeryType === 'mixed') {
|
|
||||||
steps.push('production-processes');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: Advanced Features (Optional)
|
// Phase 3: Advanced Features (Optional)
|
||||||
steps.push('quality-setup');
|
steps.push('quality-setup');
|
||||||
steps.push('team-setup');
|
steps.push('team-setup');
|
||||||
@@ -301,6 +319,7 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
|||||||
updateTenantInfo,
|
updateTenantInfo,
|
||||||
updateLocation,
|
updateLocation,
|
||||||
updateTenantId,
|
updateTenantId,
|
||||||
|
updateChildTenants,
|
||||||
updateAISuggestions,
|
updateAISuggestions,
|
||||||
updateUploadedFile,
|
updateUploadedFile,
|
||||||
setAIAnalysisComplete,
|
setAIAnalysisComplete,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -20,12 +20,24 @@ export const CompletionStep: React.FC<CompletionStepProps> = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentTenant = useCurrentTenant();
|
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' });
|
onComplete({ redirectTo: '/app/dashboard' });
|
||||||
navigate('/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' });
|
onComplete({ redirectTo: '/app/dashboard' });
|
||||||
navigate('/app/dashboard');
|
navigate('/app/dashboard');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -15,11 +15,33 @@ interface RegisterTenantStepProps {
|
|||||||
isLastStep: boolean;
|
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> = ({
|
export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||||
onComplete,
|
onComplete,
|
||||||
isFirstStep
|
isFirstStep
|
||||||
}) => {
|
}) => {
|
||||||
const wizardContext = useWizardContext();
|
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>({
|
const [formData, setFormData] = useState<BakeryRegistration>({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -27,9 +49,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
|||||||
phone: '',
|
phone: '',
|
||||||
city: 'Madrid',
|
city: 'Madrid',
|
||||||
business_type: 'bakery',
|
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 [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const registerBakery = useRegisterBakery();
|
const registerBakery = useRegisterBakery();
|
||||||
|
|
||||||
@@ -110,6 +143,12 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('📝 Registering tenant with data:', {
|
||||||
|
bakeryType: wizardContext.state.bakeryType,
|
||||||
|
business_model: formData.business_model,
|
||||||
|
formData
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tenant = await registerBakery.mutateAsync(formData);
|
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="space-y-4 md:space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
|
||||||
<Input
|
<Input
|
||||||
label="Nombre de la Panadería"
|
label={isEnterprise ? "Nombre del Obrador Central" : "Nombre de la Panadería"}
|
||||||
placeholder="Ingresa el nombre de tu panadería"
|
placeholder={isEnterprise ? "Ingresa el nombre de tu obrador central" : "Ingresa el nombre de tu panadería"}
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
@@ -191,7 +230,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<AddressAutocomplete
|
<AddressAutocomplete
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
placeholder="Enter bakery address..."
|
placeholder={isEnterprise ? "Dirección del obrador central..." : "Dirección de tu panadería..."}
|
||||||
onAddressSelect={(address) => {
|
onAddressSelect={(address) => {
|
||||||
console.log('Selected:', address.display_name);
|
console.log('Selected:', address.display_name);
|
||||||
handleAddressSelect(address);
|
handleAddressSelect(address);
|
||||||
@@ -236,10 +275,10 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
isLoading={registerBakery.isPending}
|
isLoading={registerBakery.isPending}
|
||||||
loadingText="Registrando..."
|
loadingText={isEnterprise ? "Registrando obrador..." : "Registrando..."}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Crear Panadería y Continuar
|
{isEnterprise ? "Crear Obrador Central y Continuar" : "Crear Panadería y Continuar"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
|
|||||||
// Core Onboarding Steps
|
// Core Onboarding Steps
|
||||||
export { RegisterTenantStep } from './RegisterTenantStep';
|
export { RegisterTenantStep } from './RegisterTenantStep';
|
||||||
export { POIDetectionStep } from './POIDetectionStep';
|
export { POIDetectionStep } from './POIDetectionStep';
|
||||||
|
export { ChildTenantsSetupStep } from './ChildTenantsSetupStep';
|
||||||
|
|
||||||
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
|
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
|
||||||
export { FileUploadStep } from './FileUploadStep';
|
export { FileUploadStep } from './FileUploadStep';
|
||||||
@@ -17,9 +18,6 @@ export { UploadSalesDataStep } from './UploadSalesDataStep';
|
|||||||
export { default as ProductCategorizationStep } from './ProductCategorizationStep';
|
export { default as ProductCategorizationStep } from './ProductCategorizationStep';
|
||||||
export { default as InitialStockEntryStep } from './InitialStockEntryStep';
|
export { default as InitialStockEntryStep } from './InitialStockEntryStep';
|
||||||
|
|
||||||
// Production Steps
|
|
||||||
export { default as ProductionProcessesStep } from './ProductionProcessesStep';
|
|
||||||
|
|
||||||
// ML & Finalization
|
// ML & Finalization
|
||||||
export { MLTrainingStep } from './MLTrainingStep';
|
export { MLTrainingStep } from './MLTrainingStep';
|
||||||
export { CompletionStep } from './CompletionStep';
|
export { CompletionStep } from './CompletionStep';
|
||||||
@@ -32,7 +32,7 @@ export const StepProgress: React.FC<StepProgressProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center sm:text-right">
|
<div className="text-center sm:text-right">
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
<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,
|
current: currentStepIndex + 1,
|
||||||
total: steps.length
|
total: steps.length
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
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 { apiClient } from '../../../api/client';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useDemoTour, getTourState, trackTourEvent } from '../../../features/demo-onboarding';
|
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 = () => {
|
export const DemoBanner: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation('demo');
|
||||||
const { startTour, resumeTour } = useDemoTour();
|
const { startTour, resumeTour } = useDemoTour();
|
||||||
const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true');
|
const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true');
|
||||||
const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at'));
|
const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at'));
|
||||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||||
const [canExtend, setCanExtend] = useState(true);
|
|
||||||
const [extending, setExtending] = useState(false);
|
|
||||||
const [showExitModal, setShowExitModal] = useState(false);
|
const [showExitModal, setShowExitModal] = useState(false);
|
||||||
|
|
||||||
// Memoize tour state to prevent re-renders
|
// Memoize tour state to prevent re-renders
|
||||||
@@ -42,7 +42,7 @@ export const DemoBanner: React.FC = () => {
|
|||||||
const diff = expiryTime - now;
|
const diff = expiryTime - now;
|
||||||
|
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
setTimeRemaining('Sesión expirada');
|
setTimeRemaining(t('banner.session_expired', 'Sesión expirada'));
|
||||||
await handleExpiration();
|
await handleExpiration();
|
||||||
} else {
|
} else {
|
||||||
const minutes = Math.floor(diff / 60000);
|
const minutes = Math.floor(diff / 60000);
|
||||||
@@ -81,27 +81,6 @@ export const DemoBanner: React.FC = () => {
|
|||||||
navigate('/demo');
|
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 () => {
|
const handleEndSession = async () => {
|
||||||
setShowExitModal(true);
|
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-4 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-5 h-5" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm bg-white/20 rounded-md px-3 py-1">
|
<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 && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -178,10 +157,14 @@ export const DemoBanner: React.FC = () => {
|
|||||||
<BookOpen className="w-4 h-4" />
|
<BookOpen className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
{tourState && tourState.currentStep > 0 && !tourState.completed
|
{tourState && tourState.currentStep > 0 && !tourState.completed
|
||||||
? 'Continuar Tutorial'
|
? t('banner.continue_tutorial')
|
||||||
: 'Ver 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>
|
||||||
<span className="sm:hidden">Tutorial</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Create account CTA */}
|
{/* 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"
|
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" />
|
<Sparkles className="w-4 h-4" />
|
||||||
<span className="hidden lg:inline">¡Crear Cuenta Gratis!</span>
|
<span>{t('banner.create_account')}</span>
|
||||||
<span className="lg:hidden">Crear Cuenta</span>
|
|
||||||
</button>
|
</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 */}
|
{/* End session */}
|
||||||
<button
|
<button
|
||||||
onClick={handleEndSession}
|
onClick={handleEndSession}
|
||||||
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm font-medium transition-colors"
|
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" />
|
<X className="w-4 h-4 sm:hidden" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,26 +199,39 @@ export const DemoBanner: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">
|
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">
|
||||||
¿Seguro que quieres salir?
|
{t('exit_modal.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[var(--text-secondary)] text-sm leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
||||||
<p className="text-sm font-semibold text-[var(--text-primary)] mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
¿Te gusta lo que ves?
|
<Sparkles className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
</p>
|
<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">
|
<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>
|
</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
|
<button
|
||||||
onClick={handleCreateAccount}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -256,13 +240,13 @@ export const DemoBanner: React.FC = () => {
|
|||||||
onClick={() => setShowExitModal(false)}
|
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)]"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={confirmEndSession}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const getDriverConfig = (
|
|||||||
closeBtnText: '×',
|
closeBtnText: '×',
|
||||||
nextBtnText: 'Siguiente →',
|
nextBtnText: 'Siguiente →',
|
||||||
prevBtnText: '← Anterior',
|
prevBtnText: '← Anterior',
|
||||||
progressText: 'Paso {{current}} de {{total}}',
|
progressText: 'Paso {current} de {total}',
|
||||||
|
|
||||||
popoverClass: 'bakery-tour-popover',
|
popoverClass: 'bakery-tour-popover',
|
||||||
popoverOffset: 10,
|
popoverOffset: 10,
|
||||||
|
|||||||
@@ -61,5 +61,31 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loading_accounts": "Error loading demo accounts",
|
"loading_accounts": "Error loading demo accounts",
|
||||||
"creating_session": "Error creating demo session"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"steps": {
|
"steps": {
|
||||||
"setup": {
|
"setup": {
|
||||||
"title": "Register Bakery",
|
"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": {
|
"poi_detection": {
|
||||||
"title": "Location Analysis",
|
"title": "Location Analysis",
|
||||||
|
|||||||
@@ -61,5 +61,31 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loading_accounts": "Error al cargar las cuentas demo",
|
"loading_accounts": "Error al cargar las cuentas demo",
|
||||||
"creating_session": "Error al crear sesión 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
"title": "Registrar Panadería",
|
"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": {
|
"poi_detection": {
|
||||||
"title": "Análisis de Ubicación",
|
"title": "Análisis de Ubicación",
|
||||||
|
|||||||
@@ -61,5 +61,31 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loading_accounts": "Errorea demo kontuak kargatzean",
|
"loading_accounts": "Errorea demo kontuak kargatzean",
|
||||||
"creating_session": "Errorea demo saioa sortzean"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
"title": "Okindegia Erregistratu",
|
"title": "Okindegia Erregistratu",
|
||||||
"description": "Oinarrizko informazioa"
|
"title_enterprise": "Okindegi Zentrala Erregistratu",
|
||||||
|
"description": "Oinarrizko informazioa",
|
||||||
|
"description_enterprise": "Okindegi zentralaren informazioa"
|
||||||
},
|
},
|
||||||
"poi_detection": {
|
"poi_detection": {
|
||||||
"title": "Kokapen Analisia",
|
"title": "Kokapen Analisia",
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { Button } from '../../../../components/ui/Button';
|
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, EmptyState } from '../../../../components/ui';
|
||||||
import { Card, CardHeader, CardBody } from '../../../../components/ui/Card';
|
|
||||||
import { Badge } from '../../../../components/ui/Badge';
|
|
||||||
import { Tooltip } from '../../../../components/ui/Tooltip';
|
|
||||||
import { useTenant } from '../../../../stores/tenant.store';
|
import { useTenant } from '../../../../stores/tenant.store';
|
||||||
import { useAuthUser } from '../../../../stores/auth.store';
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
import {
|
import {
|
||||||
@@ -13,12 +10,6 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
Calendar,
|
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
Mail,
|
|
||||||
Globe,
|
|
||||||
MoreHorizontal,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Crown,
|
Crown,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -31,6 +22,8 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const { currentTenant, availableTenants, switchTenant } = useTenant();
|
const { currentTenant, availableTenants, switchTenant } = useTenant();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
|
|
||||||
const handleAddNewOrganization = () => {
|
const handleAddNewOrganization = () => {
|
||||||
navigate('/app/onboarding?new=true');
|
navigate('/app/onboarding?new=true');
|
||||||
@@ -44,30 +37,14 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManageTenant = (tenantId: string) => {
|
const handleManageTenant = () => {
|
||||||
// Navigate to tenant settings
|
|
||||||
navigate(`/app/database/bakery-config`);
|
navigate(`/app/database/bakery-config`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManageTeam = (tenantId: string) => {
|
const handleManageTeam = () => {
|
||||||
// Navigate to team management
|
|
||||||
navigate(`/app/database/team`);
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||||
year: 'numeric',
|
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 (
|
return (
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('settings:organization.title', 'Mis Organizaciones')}
|
title="Organizaciones"
|
||||||
description={t('settings:organization.description', 'Gestiona tus panaderías y negocios')}
|
description="Gestiona tus organizaciones y configuraciones"
|
||||||
actions={
|
actions={[
|
||||||
<Button
|
{
|
||||||
onClick={handleAddNewOrganization}
|
id: "add-organization",
|
||||||
variant="primary"
|
label: "Nueva Organización",
|
||||||
size="lg"
|
variant: "primary" as const,
|
||||||
className="flex items-center gap-2"
|
icon: Plus,
|
||||||
>
|
onClick: handleAddNewOrganization,
|
||||||
<Plus className="w-4 h-4" />
|
tooltip: "Crear nueva organización",
|
||||||
Nueva Organización
|
size: "md"
|
||||||
</Button>
|
}
|
||||||
}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<StatsGrid
|
||||||
<Card>
|
stats={stats}
|
||||||
<CardBody className="text-center">
|
columns={3}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card>
|
{/* Search and Filter Controls */}
|
||||||
<CardBody className="text-center">
|
<SearchAndFilter
|
||||||
<Crown className="w-8 h-8 text-[var(--color-warning)] mx-auto mb-2" />
|
searchValue={searchTerm}
|
||||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
onSearchChange={setSearchTerm}
|
||||||
{availableTenants?.filter(t => t.owner_id === user?.id).length || 0}
|
searchPlaceholder="Buscar por nombre, tipo o ciudad..."
|
||||||
</div>
|
filters={[
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Propietario</div>
|
{
|
||||||
</CardBody>
|
key: 'role',
|
||||||
</Card>
|
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>
|
{/* Organizations Grid */}
|
||||||
<CardBody className="text-center">
|
{filteredTenants && filteredTenants.length > 0 ? (
|
||||||
<Shield className="w-8 h-8 text-[var(--color-primary)] mx-auto mb-2" />
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
{filteredTenants.map((tenant) => {
|
||||||
{availableTenants?.filter(t => t.owner_id !== user?.id).length || 0}
|
const isActive = currentTenant?.id === tenant.id;
|
||||||
</div>
|
const isOwnerRole = isOwner(tenant.owner_id);
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Miembro</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Organizations List */}
|
const statusConfig = {
|
||||||
<div className="space-y-4">
|
color: isActive ? getStatusColor('completed') : getStatusColor('default'),
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Tus Organizaciones</h3>
|
text: isActive ? 'Activa' : 'Inactiva',
|
||||||
|
icon: isOwnerRole ? Crown : Shield,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: isActive
|
||||||
|
};
|
||||||
|
|
||||||
{availableTenants && availableTenants.length > 0 ? (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<StatusCard
|
||||||
{availableTenants.map((tenant) => (
|
|
||||||
<Card
|
|
||||||
key={tenant.id}
|
key={tenant.id}
|
||||||
className={`transition-all duration-200 hover:shadow-lg ${
|
id={tenant.id}
|
||||||
currentTenant?.id === tenant.id
|
statusIndicator={statusConfig}
|
||||||
? 'ring-2 ring-[var(--color-primary)]/20 bg-[var(--color-primary)]/5'
|
title={tenant.name}
|
||||||
: 'hover:border-[var(--color-primary)]/30'
|
subtitle={tenant.business_type || 'Organización'}
|
||||||
}`}
|
primaryValue={isOwnerRole ? 'Propietario' : 'Miembro'}
|
||||||
>
|
primaryValueLabel=""
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4">
|
secondaryInfo={
|
||||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
tenant.city ? {
|
||||||
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
label: 'Ubicación',
|
||||||
<Building2 className="w-6 h-6 text-[var(--color-primary)]" />
|
value: tenant.city
|
||||||
</div>
|
} : undefined
|
||||||
<div className="flex-1 min-w-0">
|
}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
metadata={[
|
||||||
<h4 className="font-semibold text-[var(--text-primary)] truncate">
|
`Creada ${formatDate(tenant.created_at)}`,
|
||||||
{tenant.name}
|
...(tenant.address ? [tenant.address] : []),
|
||||||
</h4>
|
...(tenant.phone ? [tenant.phone] : [])
|
||||||
{currentTenant?.id === tenant.id && (
|
]}
|
||||||
<Badge variant="primary" size="sm">Activa</Badge>
|
onClick={() => {
|
||||||
)}
|
if (!isActive) {
|
||||||
</div>
|
handleSwitchToTenant(tenant.id);
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
}
|
||||||
{getRoleIcon(tenant.owner_id)}
|
}}
|
||||||
<span>{getRoleLabel(tenant.owner_id)}</span>
|
actions={[
|
||||||
</div>
|
// Primary action - Switch or View Dashboard
|
||||||
</div>
|
{
|
||||||
</div>
|
label: isActive ? 'Ver Dashboard' : 'Cambiar',
|
||||||
|
icon: isActive ? Eye : ArrowRight,
|
||||||
<div className="flex gap-1">
|
variant: isActive ? 'outline' : 'primary',
|
||||||
<Tooltip content="Configurar organización">
|
priority: 'primary',
|
||||||
<Button
|
onClick: () => {
|
||||||
variant="ghost"
|
if (isActive) {
|
||||||
size="sm"
|
navigate('/app/dashboard');
|
||||||
onClick={() => handleManageTenant(tenant.id)}
|
} else {
|
||||||
className="w-8 h-8 p-0"
|
handleSwitchToTenant(tenant.id);
|
||||||
>
|
}
|
||||||
<Settings className="w-4 h-4" />
|
}
|
||||||
</Button>
|
},
|
||||||
</Tooltip>
|
// Settings action
|
||||||
|
{
|
||||||
{user?.id === tenant.owner_id && (
|
label: 'Configuración',
|
||||||
<Tooltip content="Gestionar equipo">
|
icon: Settings,
|
||||||
<Button
|
priority: 'secondary',
|
||||||
variant="ghost"
|
onClick: () => {
|
||||||
size="sm"
|
if (!isActive) {
|
||||||
onClick={() => handleManageTeam(tenant.id)}
|
handleSwitchToTenant(tenant.id);
|
||||||
className="w-8 h-8 p-0"
|
}
|
||||||
>
|
handleManageTenant();
|
||||||
<Users className="w-4 h-4" />
|
}
|
||||||
</Button>
|
},
|
||||||
</Tooltip>
|
// Team management - only for owners
|
||||||
)}
|
...(isOwnerRole ? [{
|
||||||
</div>
|
label: 'Equipo',
|
||||||
</CardHeader>
|
icon: Users,
|
||||||
|
priority: 'secondary' as const,
|
||||||
<CardBody className="pt-0">
|
highlighted: true,
|
||||||
{/* Organization details */}
|
onClick: () => {
|
||||||
<div className="space-y-2 mb-4">
|
if (!isActive) {
|
||||||
{tenant.business_type && (
|
handleSwitchToTenant(tenant.id);
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
}
|
||||||
<Badge variant="outline" size="sm">{tenant.business_type}</Badge>
|
handleManageTeam();
|
||||||
</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>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<EmptyState
|
||||||
|
icon={Building2}
|
||||||
{tenant.city && (
|
title="No se encontraron organizaciones"
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
description={searchTerm || roleFilter
|
||||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
? "Intenta ajustar los filtros de búsqueda"
|
||||||
<span>{tenant.city}</span>
|
: "Crea tu primera organización para comenzar a usar Bakery IA"
|
||||||
</div>
|
}
|
||||||
)}
|
actionLabel="Nueva Organización"
|
||||||
|
actionIcon={Plus}
|
||||||
{tenant.phone && (
|
onAction={handleAddNewOrganization}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,80 +86,20 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
subscriptionService.fetchAvailablePlans()
|
subscriptionService.fetchAvailablePlans()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FIX: Handle demo mode or missing subscription data
|
// CRITICAL: No more mock data - show real errors instead
|
||||||
if (!usage || !usage.usage) {
|
if (!usage || !usage.usage) {
|
||||||
// If no usage data, likely a demo tenant - create mock data
|
throw new Error('No subscription found. Please contact support or create a new subscription.');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setUsageSummary(usage);
|
||||||
setAvailablePlans(plans);
|
setAvailablePlans(plans);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading subscription data:', 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 {
|
} finally {
|
||||||
setSubscriptionLoading(false);
|
setSubscriptionLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const DemoPage = () => {
|
|||||||
// Helper function to calculate estimated progress based on elapsed time
|
// Helper function to calculate estimated progress based on elapsed time
|
||||||
const calculateEstimatedProgress = (tier: string, startTime: number): number => {
|
const calculateEstimatedProgress = (tier: string, startTime: number): number => {
|
||||||
const elapsed = Date.now() - startTime;
|
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);
|
const linearProgress = Math.min(95, (elapsed / duration) * 100);
|
||||||
// Logarithmic curve for natural feel - starts fast, slows down
|
// Logarithmic curve for natural feel - starts fast, slows down
|
||||||
return Math.min(95, Math.round(linearProgress * (1 - Math.exp(-elapsed / 10000))));
|
return Math.min(95, Math.round(linearProgress * (1 - Math.exp(-elapsed / 10000))));
|
||||||
@@ -150,17 +150,16 @@ const DemoPage = () => {
|
|||||||
|
|
||||||
const getLoadingMessage = (tier, progress) => {
|
const getLoadingMessage = (tier, progress) => {
|
||||||
if (tier === 'enterprise') {
|
if (tier === 'enterprise') {
|
||||||
if (progress < 15) return 'Preparando entorno enterprise...';
|
if (progress < 20) return 'Iniciando tu demostración...';
|
||||||
if (progress < 35) return 'Creando obrador central en Madrid...';
|
if (progress < 50) return 'Creando tu panadería central...';
|
||||||
if (progress < 55) return 'Configurando outlets en Barcelona, Valencia y Bilbao...';
|
if (progress < 80) return 'Configurando tus sucursales...';
|
||||||
if (progress < 75) return 'Generando rutas de distribución optimizadas...';
|
if (progress < 95) return 'Preparando datos finales...';
|
||||||
if (progress < 90) return 'Configurando red de distribución...';
|
return 'Casi listo...';
|
||||||
return 'Finalizando configuración enterprise...';
|
|
||||||
} else {
|
} else {
|
||||||
if (progress < 30) return 'Preparando tu panadería...';
|
if (progress < 25) return 'Iniciando tu panadería...';
|
||||||
if (progress < 60) return 'Configurando inventario y recetas...';
|
if (progress < 70) return 'Cargando productos y datos...';
|
||||||
if (progress < 85) return 'Generando datos de ventas y producción...';
|
if (progress < 95) return 'Preparando datos finales...';
|
||||||
return 'Finalizando configuración...';
|
return 'Casi listo...';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -806,11 +805,8 @@ const DemoPage = () => {
|
|||||||
<ModalHeader
|
<ModalHeader
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-3">
|
<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="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)]">
|
||||||
<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">
|
|
||||||
Configurando Tu Demo
|
Configurando Tu Demo
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -819,145 +815,40 @@ const DemoPage = () => {
|
|||||||
/>
|
/>
|
||||||
<ModalBody padding="xl">
|
<ModalBody padding="xl">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Overall Progress Section with Enhanced Visual */}
|
{/* Overall Progress Section */}
|
||||||
<div className="text-center space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-baseline mb-3">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Progreso Total</span>
|
<span className="text-lg font-medium text-[var(--text-primary)]">
|
||||||
<span className="text-3xl font-bold bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
|
{getLoadingMessage(creatingTier, cloneProgress.overall)}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-[var(--color-primary)]">
|
||||||
{cloneProgress.overall}%
|
{cloneProgress.overall}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<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}%` }}
|
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>
|
</div>
|
||||||
|
|
||||||
{estimatedRemainingSeconds !== null && estimatedRemainingSeconds > 0 && (
|
{estimatedRemainingSeconds !== null && estimatedRemainingSeconds > 0 && (
|
||||||
<div className="flex items-center justify-center gap-2 mt-4">
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
<Clock className="w-4 h-4 text-[var(--text-tertiary)]" />
|
<Clock className="w-4 h-4" />
|
||||||
<span className="text-sm text-[var(--text-secondary)]">
|
<span>Aproximadamente {estimatedRemainingSeconds}s restantes</span>
|
||||||
Aproximadamente <span className="font-semibold text-[var(--color-primary)]">{estimatedRemainingSeconds}s</span> restantes
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-base text-[var(--text-secondary)] font-medium mt-4">
|
|
||||||
{getLoadingMessage(creatingTier, cloneProgress.overall)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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 */}
|
{/* Information Box */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="rounded-lg p-4 bg-[var(--bg-secondary)] border border-[var(--border-primary)]">
|
||||||
{['Barcelona', 'Valencia', 'Bilbao'].map((city, index) => (
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
<div
|
{creatingTier === 'enterprise'
|
||||||
key={index}
|
? 'Estamos preparando tu panadería con una tienda principal y 3 sucursales conectadas'
|
||||||
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"
|
: 'Estamos preparando tu panadería con productos, recetas y ventas de ejemplo'}
|
||||||
>
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -177,6 +177,19 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|||||||
return <>{children}</>;
|
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
|
// Get user roles and permissions
|
||||||
const globalUserRoles = user?.role ? [user.role] : [];
|
const globalUserRoles = user?.role ? [user.role] : [];
|
||||||
const tenantRole = currentTenantAccess?.role;
|
const tenantRole = currentTenantAccess?.role;
|
||||||
@@ -189,11 +202,28 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|||||||
// Check if user can access this route
|
// Check if user can access this route
|
||||||
const canAccess = canAccessRoute(route, isAuthenticated, allUserRoles, tenantPermissions);
|
const canAccess = canAccessRoute(route, isAuthenticated, allUserRoles, tenantPermissions);
|
||||||
|
|
||||||
|
console.log('🔐 [ProtectedRoute] Access check:', {
|
||||||
|
route: route.path,
|
||||||
|
canAccess,
|
||||||
|
globalUserRoles,
|
||||||
|
tenantRoles,
|
||||||
|
allUserRoles,
|
||||||
|
tenantPermissions,
|
||||||
|
currentTenantAccess
|
||||||
|
});
|
||||||
|
|
||||||
if (!canAccess) {
|
if (!canAccess) {
|
||||||
// Check if it's a permission issue or role issue
|
// 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));
|
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) {
|
if (!hasRequiredRoles) {
|
||||||
return <UnauthorizedPage />;
|
return <UnauthorizedPage />;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.middleware.request_id import RequestIDMiddleware
|
|||||||
from app.middleware.auth import AuthMiddleware
|
from app.middleware.auth import AuthMiddleware
|
||||||
from app.middleware.logging import LoggingMiddleware
|
from app.middleware.logging import LoggingMiddleware
|
||||||
from app.middleware.rate_limit import RateLimitMiddleware
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
|
from app.middleware.rate_limiting import APIRateLimitMiddleware
|
||||||
from app.middleware.subscription import SubscriptionMiddleware
|
from app.middleware.subscription import SubscriptionMiddleware
|
||||||
from app.middleware.demo_middleware import DemoMiddleware
|
from app.middleware.demo_middleware import DemoMiddleware
|
||||||
from app.middleware.read_only_mode import ReadOnlyModeMiddleware
|
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)
|
# Custom middleware - Add in REVERSE order (last added = first executed)
|
||||||
# Execution order: RequestIDMiddleware -> DemoMiddleware -> AuthMiddleware -> ReadOnlyModeMiddleware -> SubscriptionMiddleware -> RateLimitMiddleware -> LoggingMiddleware
|
# Execution order: RequestIDMiddleware -> DemoMiddleware -> AuthMiddleware -> ReadOnlyModeMiddleware -> SubscriptionMiddleware -> APIRateLimitMiddleware -> RateLimitMiddleware -> LoggingMiddleware
|
||||||
app.add_middleware(LoggingMiddleware) # Executes 7th (outermost)
|
app.add_middleware(LoggingMiddleware) # Executes 8th (outermost)
|
||||||
app.add_middleware(RateLimitMiddleware, calls_per_minute=300) # Executes 6th
|
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(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(ReadOnlyModeMiddleware, tenant_service_url=settings.TENANT_SERVICE_URL) # Executes 4th - Enforce read-only mode
|
||||||
app.add_middleware(AuthMiddleware) # Executes 3rd - Checks for demo context
|
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)
|
await initialize_redis(settings.REDIS_URL, db=0, max_connections=50)
|
||||||
redis_client = await get_redis_client()
|
redis_client = await get_redis_client()
|
||||||
logger.info("Connected to Redis for SSE streaming")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to Redis: {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(
|
metrics_collector.register_counter(
|
||||||
"gateway_auth_requests_total",
|
"gateway_auth_requests_total",
|
||||||
|
|||||||
260
gateway/app/middleware/rate_limiting.py
Normal file
260
gateway/app/middleware/rate_limiting.py
Normal 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)
|
||||||
@@ -48,6 +48,12 @@ async def get_tenant_children(request: Request, tenant_id: str = Path(...)):
|
|||||||
"""Get tenant children"""
|
"""Get tenant children"""
|
||||||
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/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"])
|
@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 = ""):
|
async def proxy_tenant_children(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
"""Proxy tenant children requests to tenant service"""
|
"""Proxy tenant children requests to tenant service"""
|
||||||
|
|||||||
@@ -210,12 +210,11 @@ CREATE TABLE user_onboarding_summary (
|
|||||||
8. `product-categorization` - Advanced categorization (optional)
|
8. `product-categorization` - Advanced categorization (optional)
|
||||||
9. `suppliers-setup` - Suppliers configuration
|
9. `suppliers-setup` - Suppliers configuration
|
||||||
10. `recipes-setup` - Production recipes (optional)
|
10. `recipes-setup` - Production recipes (optional)
|
||||||
11. `production-processes` - Finishing processes (optional)
|
11. `quality-setup` - Quality standards (optional)
|
||||||
12. `quality-setup` - Quality standards (optional)
|
12. `team-setup` - Team members (optional)
|
||||||
13. `team-setup` - Team members (optional)
|
13. `ml-training` - AI model training (requires POI detection)
|
||||||
14. `ml-training` - AI model training (requires POI detection)
|
14. `setup-review` - Review all configuration
|
||||||
15. `setup-review` - Review all configuration
|
15. `completion` - Onboarding completed
|
||||||
16. `completion` - Onboarding completed
|
|
||||||
|
|
||||||
**login_attempts**
|
**login_attempts**
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ ONBOARDING_STEPS = [
|
|||||||
"user_registered", # Auto-completed: User account created
|
"user_registered", # Auto-completed: User account created
|
||||||
|
|
||||||
# Phase 1: Discovery
|
# 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
|
# Phase 2: Core Setup
|
||||||
"setup", # Basic bakery setup and tenant creation
|
"setup", # Basic bakery setup and tenant creation
|
||||||
# NOTE: POI detection now happens automatically in background during tenant registration
|
# 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)
|
# Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
|
||||||
"upload-sales-data", # File upload, validation, and AI classification
|
"upload-sales-data", # File upload, validation, and AI classification
|
||||||
"inventory-review", # Review and confirm AI-detected products with type selection
|
"inventory-review", # Review and confirm AI-detected products with type selection
|
||||||
@@ -63,7 +66,6 @@ ONBOARDING_STEPS = [
|
|||||||
|
|
||||||
# Phase 3: Advanced Configuration (all optional)
|
# Phase 3: Advanced Configuration (all optional)
|
||||||
"recipes-setup", # Production recipes (conditional: production/mixed bakery)
|
"recipes-setup", # Production recipes (conditional: production/mixed bakery)
|
||||||
"production-processes", # Finishing processes (conditional: retail/mixed bakery)
|
|
||||||
"quality-setup", # Quality standards and templates
|
"quality-setup", # Quality standards and templates
|
||||||
"team-setup", # Team members and permissions
|
"team-setup", # Team members and permissions
|
||||||
|
|
||||||
@@ -79,10 +81,14 @@ STEP_DEPENDENCIES = {
|
|||||||
# Discovery phase
|
# Discovery phase
|
||||||
"bakery-type-selection": ["user_registered"],
|
"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"],
|
"setup": ["user_registered", "bakery-type-selection"],
|
||||||
# NOTE: POI detection removed from steps - now happens automatically in background
|
# 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
|
# AI-Assisted Inventory Setup - REFACTORED into 3 sequential steps
|
||||||
"upload-sales-data": ["user_registered", "setup"],
|
"upload-sales-data": ["user_registered", "setup"],
|
||||||
"inventory-review": ["user_registered", "setup", "upload-sales-data"],
|
"inventory-review": ["user_registered", "setup", "upload-sales-data"],
|
||||||
@@ -96,7 +102,6 @@ STEP_DEPENDENCIES = {
|
|||||||
|
|
||||||
# Advanced configuration (optional, minimal dependencies)
|
# Advanced configuration (optional, minimal dependencies)
|
||||||
"recipes-setup": ["user_registered", "setup"],
|
"recipes-setup": ["user_registered", "setup"],
|
||||||
"production-processes": ["user_registered", "setup"],
|
|
||||||
"quality-setup": ["user_registered", "setup"],
|
"quality-setup": ["user_registered", "setup"],
|
||||||
"team-setup": ["user_registered", "setup"],
|
"team-setup": ["user_registered", "setup"],
|
||||||
|
|
||||||
@@ -272,7 +277,7 @@ class OnboardingService:
|
|||||||
"""Check if user can complete a specific step"""
|
"""Check if user can complete a specific step"""
|
||||||
|
|
||||||
# Get required dependencies for this 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:
|
if not required_steps:
|
||||||
return True # No dependencies
|
return True # No dependencies
|
||||||
@@ -280,8 +285,29 @@ class OnboardingService:
|
|||||||
# Check if all required steps are completed
|
# Check if all required steps are completed
|
||||||
user_progress_data = await self._get_user_onboarding_data(user_id)
|
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:
|
for required_step in required_steps:
|
||||||
if not user_progress_data.get(required_step, {}).get("completed", False):
|
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
|
return False
|
||||||
|
|
||||||
# SPECIAL VALIDATION FOR ML TRAINING STEP
|
# SPECIAL VALIDATION FOR ML TRAINING STEP
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ class EnhancedAuthService:
|
|||||||
onboarding_repo = OnboardingRepository(db_session)
|
onboarding_repo = OnboardingRepository(db_session)
|
||||||
plan_data = {
|
plan_data = {
|
||||||
"subscription_plan": user_data.subscription_plan or "starter",
|
"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,
|
"use_trial": user_data.use_trial or False,
|
||||||
"payment_method_id": user_data.payment_method_id,
|
"payment_method_id": user_data.payment_method_id,
|
||||||
"saved_at": datetime.now(timezone.utc).isoformat()
|
"saved_at": datetime.now(timezone.utc).isoformat()
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from typing import List, Optional
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.inventory_service import InventoryService
|
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.routing import RouteBuilder
|
||||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
# Create route builder for consistent URL structure
|
# Create route builder for consistent URL structure
|
||||||
route_builder = RouteBuilder('inventory')
|
route_builder = RouteBuilder('inventory')
|
||||||
|
|
||||||
@@ -61,6 +65,58 @@ async def create_ingredient(
|
|||||||
):
|
):
|
||||||
"""Create a new ingredient (Admin/Manager only)"""
|
"""Create a new ingredient (Admin/Manager only)"""
|
||||||
try:
|
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
|
# Extract user ID - handle service tokens
|
||||||
raw_user_id = current_user.get('user_id')
|
raw_user_id = current_user.get('user_id')
|
||||||
if current_user.get('type') == 'service':
|
if current_user.get('type') == 'service':
|
||||||
@@ -73,13 +129,28 @@ async def create_ingredient(
|
|||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
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
|
return ingredient
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(e)
|
detail=str(e)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to create ingredient",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to create ingredient"
|
detail="Failed to create ingredient"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import logging
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..services.recipe_service import RecipeService
|
from ..services.recipe_service import RecipeService
|
||||||
@@ -51,6 +52,46 @@ async def create_recipe(
|
|||||||
):
|
):
|
||||||
"""Create a new recipe"""
|
"""Create a new recipe"""
|
||||||
try:
|
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_service = RecipeService(db)
|
||||||
|
|
||||||
recipe_dict = recipe_data.dict(exclude={"ingredients"})
|
recipe_dict = recipe_data.dict(exclude={"ingredients"})
|
||||||
@@ -67,6 +108,8 @@ async def create_recipe(
|
|||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
raise HTTPException(status_code=400, detail=result["error"])
|
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"])
|
return RecipeResponse(**result["data"])
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
|||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import structlog
|
import structlog
|
||||||
|
import httpx
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -42,6 +43,51 @@ async def create_supplier(
|
|||||||
):
|
):
|
||||||
"""Create a new supplier"""
|
"""Create a new supplier"""
|
||||||
try:
|
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)
|
service = SupplierService(db)
|
||||||
|
|
||||||
# Get user role from current_user dict
|
# Get user role from current_user dict
|
||||||
@@ -53,7 +99,12 @@ async def create_supplier(
|
|||||||
created_by=current_user["user_id"],
|
created_by=current_user["user_id"],
|
||||||
created_by_role=user_role
|
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)
|
return SupplierResponse.from_orm(supplier)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ async def clone_demo_data(
|
|||||||
plan=subscription_data.get('plan', 'professional'),
|
plan=subscription_data.get('plan', 'professional'),
|
||||||
status=subscription_data.get('status', 'active'),
|
status=subscription_data.get('status', 'active'),
|
||||||
monthly_price=subscription_data.get('monthly_price', 299.00),
|
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_users=subscription_data.get('max_users', 10),
|
||||||
max_locations=subscription_data.get('max_locations', 3),
|
max_locations=subscription_data.get('max_locations', 3),
|
||||||
max_products=subscription_data.get('max_products', 500),
|
max_products=subscription_data.get('max_products', 500),
|
||||||
@@ -206,6 +207,18 @@ async def clone_demo_data(
|
|||||||
subscription_data.get('next_billing_date'),
|
subscription_data.get('next_billing_date'),
|
||||||
session_time,
|
session_time,
|
||||||
"next_billing_date"
|
"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"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,26 @@ async def cancel_subscription(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(subscription)
|
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
|
days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -197,6 +217,26 @@ async def reactivate_subscription(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(subscription)
|
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(
|
logger.info(
|
||||||
"subscription_reactivated",
|
"subscription_reactivated",
|
||||||
tenant_id=str(tenant_id),
|
tenant_id=str(tenant_id),
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, Path
|
|||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from uuid import UUID
|
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.services.tenant_service import EnhancedTenantService
|
||||||
from app.repositories.tenant_repository import TenantRepository
|
from app.repositories.tenant_repository import TenantRepository
|
||||||
from shared.auth.decorators import get_current_user_dep
|
from shared.auth.decorators import get_current_user_dep
|
||||||
@@ -213,6 +219,242 @@ 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
|
# Register the router in the main app
|
||||||
def register_hierarchy_routes(app):
|
def register_hierarchy_routes(app):
|
||||||
"""Register hierarchy routes with the main application"""
|
"""Register hierarchy routes with the main application"""
|
||||||
|
|||||||
@@ -48,6 +48,31 @@ async def add_team_member_with_user_creation(
|
|||||||
In production, this will be replaced with an invitation-based flow.
|
In production, this will be replaced with an invitation-based flow.
|
||||||
"""
|
"""
|
||||||
try:
|
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
|
user_id_to_add = member_data.user_id
|
||||||
|
|
||||||
# If create_user is True, create the user first via auth service
|
# 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)"""
|
"""Add an existing team member to tenant (legacy endpoint)"""
|
||||||
|
|
||||||
try:
|
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(
|
result = await tenant_service.add_team_member(
|
||||||
str(tenant_id),
|
str(tenant_id),
|
||||||
user_id,
|
user_id,
|
||||||
role,
|
role,
|
||||||
current_user["user_id"]
|
current_user["user_id"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Team member added successfully",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
user_id=user_id,
|
||||||
|
role=role
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -141,6 +141,44 @@ async def register_bakery(
|
|||||||
current_user["user_id"]
|
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 was validated, redeem it now with actual tenant_id
|
||||||
if coupon_validation and coupon_validation["valid"]:
|
if coupon_validation and coupon_validation["valid"]:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -785,6 +823,48 @@ async def can_add_user(
|
|||||||
detail="Failed to check user limits"
|
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))
|
@router.get(route_builder.build_base_route("subscriptions/{tenant_id}/features/{feature}", include_tenant_prefix=False))
|
||||||
async def has_feature(
|
async def has_feature(
|
||||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
|||||||
@@ -198,7 +198,101 @@ class TenantStatsResponse(BaseModel):
|
|||||||
subscription_plan: str
|
subscription_plan: str
|
||||||
subscription_status: 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
|
@classmethod
|
||||||
def convert_uuid_to_string(cls, v):
|
def convert_uuid_to_string(cls, v):
|
||||||
"""Convert UUID objects to strings for JSON serialization"""
|
"""Convert UUID objects to strings for JSON serialization"""
|
||||||
|
|||||||
@@ -183,6 +183,74 @@ class SubscriptionLimitService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
return {"can_add": False, "reason": "Error checking limits"}
|
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]:
|
async def has_feature(self, tenant_id: str, feature: str) -> Dict[str, Any]:
|
||||||
"""Check if tenant has access to a specific feature"""
|
"""Check if tenant has access to a specific feature"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint('tenant_id')
|
sa.UniqueConstraint('tenant_id')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create subscriptions table with all current columns
|
# Create subscriptions table with all quota columns
|
||||||
op.create_table('subscriptions',
|
op.create_table('subscriptions',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('tenant_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('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
||||||
sa.Column('stripe_customer_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_users', sa.Integer(), nullable=True),
|
||||||
sa.Column('max_locations', sa.Integer(), nullable=True),
|
sa.Column('max_locations', sa.Integer(), nullable=True),
|
||||||
sa.Column('max_products', 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('features', sa.JSON(), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
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')),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"plan": "enterprise",
|
"plan": "enterprise",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"monthly_price": 1999.0,
|
"monthly_price": 1999.0,
|
||||||
|
"billing_cycle": "monthly",
|
||||||
"max_users": 50,
|
"max_users": 50,
|
||||||
"max_locations": 20,
|
"max_locations": 20,
|
||||||
"max_products": 5000,
|
"max_products": 5000,
|
||||||
@@ -38,22 +39,14 @@
|
|||||||
"cross_location_optimization": true,
|
"cross_location_optimization": true,
|
||||||
"distribution_management": true
|
"distribution_management": true
|
||||||
},
|
},
|
||||||
"trial_ends_at": "2025-02-15T06:00:00Z",
|
"trial_ends_at": "BASE_TS+60d",
|
||||||
"next_billing_date": "2025-02-01T06:00:00Z",
|
"next_billing_date": "BASE_TS+30d",
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
"stripe_subscription_id": null,
|
||||||
"updated_at": "2024-01-01T00:00:00Z",
|
"stripe_customer_id": null,
|
||||||
"enterprise_features": [
|
"cancelled_at": null,
|
||||||
"multi_location_management",
|
"cancellation_effective_date": null,
|
||||||
"centralized_inventory",
|
"created_at": "BASE_TS-90d",
|
||||||
"centralized_production",
|
"updated_at": "BASE_TS-1d"
|
||||||
"bulk_procurement",
|
|
||||||
"advanced_analytics",
|
|
||||||
"custom_reporting",
|
|
||||||
"api_access",
|
|
||||||
"priority_support",
|
|
||||||
"cross_location_optimization",
|
|
||||||
"distribution_management"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"plan": "professional",
|
"plan": "professional",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"monthly_price": 299.00,
|
"monthly_price": 299.00,
|
||||||
|
"billing_cycle": "monthly",
|
||||||
"max_users": 10,
|
"max_users": 10,
|
||||||
"max_locations": 3,
|
"max_locations": 3,
|
||||||
"max_products": 500,
|
"max_products": 500,
|
||||||
@@ -36,9 +37,13 @@
|
|||||||
"api_access": true,
|
"api_access": true,
|
||||||
"priority_support": true
|
"priority_support": true
|
||||||
},
|
},
|
||||||
"trial_ends_at": "2025-02-15T06:00:00Z",
|
"trial_ends_at": "BASE_TS+30d",
|
||||||
"next_billing_date": "2025-02-01T06:00:00Z",
|
"next_billing_date": "BASE_TS+30d",
|
||||||
"created_at": "2025-01-15T06:00:00Z",
|
"stripe_subscription_id": null,
|
||||||
"updated_at": "2025-01-15T06:00:00Z"
|
"stripe_customer_id": null,
|
||||||
|
"cancelled_at": null,
|
||||||
|
"cancellation_effective_date": null,
|
||||||
|
"created_at": "BASE_TS-30d",
|
||||||
|
"updated_at": "BASE_TS-30d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user