Improve onboarding
This commit is contained in:
@@ -209,6 +209,11 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
|
||||
await register(registrationData);
|
||||
|
||||
// CRITICAL: Store subscription_tier in localStorage for onboarding flow
|
||||
// This is required for conditional step rendering in UnifiedOnboardingWizard
|
||||
console.log('💾 Storing subscription_tier in localStorage:', selectedPlan);
|
||||
localStorage.setItem('subscription_tier', selectedPlan);
|
||||
|
||||
const successMessage = isPilot
|
||||
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
|
||||
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
|
||||
|
||||
@@ -11,11 +11,11 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
|
||||
import {
|
||||
BakeryTypeSelectionStep,
|
||||
RegisterTenantStep,
|
||||
ChildTenantsSetupStep,
|
||||
FileUploadStep,
|
||||
InventoryReviewStep,
|
||||
ProductCategorizationStep,
|
||||
InitialStockEntryStep,
|
||||
ProductionProcessesStep,
|
||||
MLTrainingStep,
|
||||
CompletionStep
|
||||
} from './steps';
|
||||
@@ -55,6 +55,26 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const wizardContext = useWizardContext();
|
||||
|
||||
// Get subscription tier from localStorage (set during demo/registration)
|
||||
const subscriptionTier = localStorage.getItem('subscription_tier') as string;
|
||||
const isEnterprise = subscriptionTier === 'enterprise';
|
||||
|
||||
// Auto-set bakeryType for enterprise users (central baker + retail outlets)
|
||||
// Do this synchronously on mount AND in useEffect to ensure it's set before VISIBLE_STEPS calculation
|
||||
React.useEffect(() => {
|
||||
if (isEnterprise && !wizardContext.state.bakeryType) {
|
||||
console.log('🏢 Auto-setting bakeryType to "mixed" for enterprise tier');
|
||||
wizardContext.updateBakeryType('mixed'); // Enterprise is always mixed (production + retail)
|
||||
}
|
||||
}, [isEnterprise, wizardContext]);
|
||||
|
||||
// CRITICAL: Set bakeryType synchronously for enterprise on initial render
|
||||
// This ensures the setup step's condition is satisfied immediately
|
||||
if (isEnterprise && !wizardContext.state.bakeryType) {
|
||||
console.log('🏢 [SYNC] Auto-setting bakeryType to "mixed" for enterprise tier');
|
||||
wizardContext.updateBakeryType('mixed');
|
||||
}
|
||||
|
||||
// All possible steps with conditional visibility
|
||||
// All step IDs match backend ONBOARDING_STEPS exactly
|
||||
const ALL_STEPS: StepConfig[] = [
|
||||
@@ -64,15 +84,50 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
title: t('onboarding:steps.bakery_type.title', 'Tipo de Panadería'),
|
||||
description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'),
|
||||
component: BakeryTypeSelectionStep,
|
||||
isConditional: true,
|
||||
condition: () => {
|
||||
// Check localStorage directly to ensure we get the current value
|
||||
const currentTier = localStorage.getItem('subscription_tier');
|
||||
return currentTier !== 'enterprise'; // Skip for enterprise users
|
||||
},
|
||||
},
|
||||
// Phase 2: Core Setup
|
||||
{
|
||||
id: 'setup',
|
||||
title: t('onboarding:steps.setup.title', 'Registrar Panadería'),
|
||||
description: t('onboarding:steps.setup.description', 'Información básica'),
|
||||
// Dynamic title based on subscription tier
|
||||
title: (() => {
|
||||
const currentTier = localStorage.getItem('subscription_tier');
|
||||
return currentTier === 'enterprise'
|
||||
? t('onboarding:steps.setup.title_enterprise', 'Registrar Obrador Central')
|
||||
: t('onboarding:steps.setup.title', 'Registrar Panadería');
|
||||
})(),
|
||||
description: (() => {
|
||||
const currentTier = localStorage.getItem('subscription_tier');
|
||||
return currentTier === 'enterprise'
|
||||
? t('onboarding:steps.setup.description_enterprise', 'Información del obrador central')
|
||||
: t('onboarding:steps.setup.description', 'Información básica');
|
||||
})(),
|
||||
component: RegisterTenantStep,
|
||||
isConditional: true,
|
||||
condition: (ctx) => ctx.state.bakeryType !== null,
|
||||
condition: (ctx) => {
|
||||
// Allow setup step if bakeryType is set OR if user is enterprise tier
|
||||
// (enterprise auto-sets bakeryType to 'mixed' automatically)
|
||||
const currentTier = localStorage.getItem('subscription_tier');
|
||||
return ctx.state.bakeryType !== null || currentTier === 'enterprise';
|
||||
},
|
||||
},
|
||||
// Enterprise-specific: Child Tenants Setup
|
||||
{
|
||||
id: 'child-tenants-setup',
|
||||
title: 'Configurar Sucursales',
|
||||
description: 'Registra las sucursales de tu red empresarial',
|
||||
component: ChildTenantsSetupStep,
|
||||
isConditional: true,
|
||||
condition: () => {
|
||||
// Check localStorage directly to ensure we get the current value
|
||||
const currentTier = localStorage.getItem('subscription_tier');
|
||||
return currentTier === 'enterprise'; // Only show for enterprise users
|
||||
},
|
||||
},
|
||||
// POI Detection removed - now happens automatically in background after tenant registration
|
||||
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
|
||||
@@ -116,15 +171,6 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
condition: (ctx) =>
|
||||
ctx.state.bakeryType === 'production' || ctx.state.bakeryType === 'mixed',
|
||||
},
|
||||
{
|
||||
id: 'production-processes',
|
||||
title: t('onboarding:steps.processes.title', 'Procesos'),
|
||||
description: t('onboarding:steps.processes.description', 'Procesos de terminado'),
|
||||
component: ProductionProcessesStep,
|
||||
isConditional: true,
|
||||
condition: (ctx) =>
|
||||
ctx.state.bakeryType === 'retail' || ctx.state.bakeryType === 'mixed',
|
||||
},
|
||||
// Phase 3: Advanced Features (Optional)
|
||||
{
|
||||
id: 'quality-setup',
|
||||
@@ -171,13 +217,15 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
|
||||
console.log('🔄 VISIBLE_STEPS recalculated:', visibleSteps.map(s => s.id));
|
||||
console.log('📊 Wizard state:', {
|
||||
bakeryType: wizardContext.state.bakeryType,
|
||||
subscriptionTier: localStorage.getItem('subscription_tier'),
|
||||
stockEntryCompleted: wizardContext.state.stockEntryCompleted,
|
||||
aiAnalysisComplete: wizardContext.state.aiAnalysisComplete,
|
||||
categorizationCompleted: wizardContext.state.categorizationCompleted,
|
||||
});
|
||||
|
||||
return visibleSteps;
|
||||
}, [wizardContext.state]);
|
||||
}, [wizardContext.state, isEnterprise]); // Added isEnterprise to dependencies
|
||||
|
||||
const isNewTenant = searchParams.get('new') === 'true';
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
@@ -192,7 +240,7 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
);
|
||||
|
||||
const markStepCompleted = useMarkStepCompleted();
|
||||
const { setCurrentTenant } = useTenantActions();
|
||||
const { setCurrentTenant, loadUserTenants } = useTenantActions();
|
||||
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
|
||||
|
||||
// Auto-complete user_registered step
|
||||
@@ -335,12 +383,74 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
if (currentStep.id === 'inventory-setup') {
|
||||
wizardContext.markStepComplete('inventoryCompleted');
|
||||
}
|
||||
if (currentStep.id === 'setup' && data?.tenant) {
|
||||
setCurrentTenant(data.tenant);
|
||||
|
||||
// If tenant info and location are available in data, update the wizard context
|
||||
if (data.tenantId && data.bakeryLocation) {
|
||||
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
|
||||
if (currentStep.id === 'setup') {
|
||||
console.log('✅ Setup step completed with data:', { hasTenant: !!data?.tenant, hasUser: !!user?.id, data });
|
||||
|
||||
if (data?.tenant) {
|
||||
setCurrentTenant(data.tenant);
|
||||
|
||||
// CRITICAL: Reload user tenants to load membership/access information
|
||||
// This ensures the user has proper permissions when navigating to dashboard
|
||||
if (user?.id) {
|
||||
console.log('🔄 [CRITICAL] Reloading user tenants after tenant creation to load access permissions...');
|
||||
await loadUserTenants();
|
||||
console.log('✅ [CRITICAL] User tenants reloaded successfully');
|
||||
} else {
|
||||
console.error('❌ [CRITICAL] Cannot reload tenants - user.id is missing!', { user });
|
||||
}
|
||||
|
||||
// If tenant info and location are available in data, update the wizard context
|
||||
if (data.tenantId && data.bakeryLocation) {
|
||||
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Setup step completed but no tenant data provided!');
|
||||
}
|
||||
}
|
||||
if (currentStep.id === 'child-tenants-setup' && data?.childTenants) {
|
||||
wizardContext.updateChildTenants(data.childTenants);
|
||||
wizardContext.markStepComplete('childTenantsCompleted');
|
||||
|
||||
// Call backend API to create child tenants
|
||||
try {
|
||||
console.log('🏢 Creating child tenants in backend...');
|
||||
const { tenantService } = await import('../../../api');
|
||||
const parentTenantId = wizardContext.state.tenantId;
|
||||
|
||||
if (!parentTenantId) {
|
||||
console.error('❌ Parent tenant ID not found in wizard context');
|
||||
throw new Error('Parent tenant not registered');
|
||||
}
|
||||
|
||||
const response = await tenantService.bulkCreateChildTenants({
|
||||
parent_tenant_id: parentTenantId,
|
||||
child_tenants: data.childTenants.map((ct: any) => ({
|
||||
name: ct.name,
|
||||
city: ct.city,
|
||||
zone: ct.zone,
|
||||
address: ct.address,
|
||||
postal_code: ct.postal_code,
|
||||
location_code: ct.location_code,
|
||||
latitude: ct.latitude,
|
||||
longitude: ct.longitude,
|
||||
phone: ct.phone,
|
||||
email: ct.email,
|
||||
})),
|
||||
auto_configure_distribution: true,
|
||||
});
|
||||
|
||||
console.log('✅ Child tenants created successfully:', {
|
||||
created: response.created_count,
|
||||
failed: response.failed_count,
|
||||
});
|
||||
|
||||
if (response.failed_count > 0) {
|
||||
console.warn('⚠️ Some child tenants failed to create:', response.failed_tenants);
|
||||
}
|
||||
} catch (childTenantError) {
|
||||
console.error('❌ Failed to create child tenants:', childTenantError);
|
||||
// Don't block the onboarding flow - log the error and continue
|
||||
// The user can add child tenants later from settings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +595,7 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-center sm:text-right">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('onboarding:wizard.progress.step_of', 'Paso {{current}} de {{total}}', {
|
||||
{t('onboarding:wizard.progress.step_of', 'Paso {current} de {total}', {
|
||||
current: currentStepIndex + 1,
|
||||
total: VISIBLE_STEPS.length
|
||||
})}
|
||||
|
||||
@@ -32,6 +32,17 @@ export interface InventoryItemForm {
|
||||
};
|
||||
}
|
||||
|
||||
// Child tenant interface for enterprise onboarding
|
||||
export interface ChildTenantData {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
zone?: string;
|
||||
address: string;
|
||||
postal_code: string;
|
||||
location_code: string;
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
// Discovery Phase
|
||||
bakeryType: BakeryType;
|
||||
@@ -44,6 +55,10 @@ export interface WizardState {
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
// Enterprise Setup Data
|
||||
childTenants?: ChildTenantData[]; // Child tenant locations for enterprise tier
|
||||
childTenantsCompleted: boolean;
|
||||
|
||||
// AI-Assisted Path Data
|
||||
uploadedFile?: File; // NEW: The actual file object needed for sales import API
|
||||
uploadedFileName?: string;
|
||||
@@ -82,6 +97,7 @@ export interface WizardContextValue {
|
||||
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
|
||||
updateLocation: (location: { latitude: number; longitude: number }) => void;
|
||||
updateTenantId: (tenantId: string) => void;
|
||||
updateChildTenants: (tenants: ChildTenantData[]) => void; // NEW: Store child tenants
|
||||
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
|
||||
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
|
||||
setAIAnalysisComplete: (complete: boolean) => void;
|
||||
@@ -97,6 +113,8 @@ export interface WizardContextValue {
|
||||
const initialState: WizardState = {
|
||||
bakeryType: null,
|
||||
dataSource: 'ai-assisted', // Only AI-assisted path supported now
|
||||
childTenants: undefined,
|
||||
childTenantsCompleted: false,
|
||||
aiSuggestions: [],
|
||||
aiAnalysisComplete: false,
|
||||
categorizedProducts: undefined,
|
||||
@@ -170,12 +188,16 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
||||
};
|
||||
|
||||
const updateTenantId = (tenantId: string) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tenantId
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChildTenants = (tenants: ChildTenantData[]) => {
|
||||
setState(prev => ({ ...prev, childTenants: tenants }));
|
||||
};
|
||||
|
||||
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
|
||||
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
|
||||
};
|
||||
@@ -251,15 +273,11 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
||||
steps.push('suppliers-setup');
|
||||
steps.push('inventory-setup');
|
||||
|
||||
// Conditional: Recipes vs Processes
|
||||
// Conditional: Recipes
|
||||
if (state.bakeryType === 'production' || state.bakeryType === 'mixed') {
|
||||
steps.push('recipes-setup');
|
||||
}
|
||||
|
||||
if (state.bakeryType === 'retail' || state.bakeryType === 'mixed') {
|
||||
steps.push('production-processes');
|
||||
}
|
||||
|
||||
// Phase 3: Advanced Features (Optional)
|
||||
steps.push('quality-setup');
|
||||
steps.push('team-setup');
|
||||
@@ -301,6 +319,7 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
||||
updateTenantInfo,
|
||||
updateLocation,
|
||||
updateTenantId,
|
||||
updateChildTenants,
|
||||
updateAISuggestions,
|
||||
updateUploadedFile,
|
||||
setAIAnalysisComplete,
|
||||
|
||||
@@ -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 currentTenant = useCurrentTenant();
|
||||
|
||||
const handleStartUsingSystem = () => {
|
||||
const handleStartUsingSystem = async () => {
|
||||
// CRITICAL: Ensure tenant access is loaded before navigating
|
||||
console.log('🔄 [CompletionStep] Ensuring tenant setup is complete before dashboard navigation...');
|
||||
|
||||
// Small delay to ensure any pending state updates complete
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
onComplete({ redirectTo: '/app/dashboard' });
|
||||
navigate('/app/dashboard');
|
||||
};
|
||||
|
||||
const handleExploreDashboard = () => {
|
||||
const handleExploreDashboard = async () => {
|
||||
// CRITICAL: Ensure tenant access is loaded before navigating
|
||||
console.log('🔄 [CompletionStep] Ensuring tenant setup is complete before dashboard navigation...');
|
||||
|
||||
// Small delay to ensure any pending state updates complete
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
onComplete({ redirectTo: '/app/dashboard' });
|
||||
navigate('/app/dashboard');
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// Map bakeryType to business_model
|
||||
const getBakeryBusinessModel = (bakeryType: string | null): string => {
|
||||
switch (bakeryType) {
|
||||
case 'production':
|
||||
return 'central_baker_satellite'; // Production-focused bakery
|
||||
case 'retail':
|
||||
return 'retail_bakery'; // Retail/finishing bakery
|
||||
case 'mixed':
|
||||
return 'hybrid_bakery'; // Mixed model (enterprise or hybrid)
|
||||
default:
|
||||
return 'individual_bakery'; // Default fallback
|
||||
}
|
||||
};
|
||||
|
||||
export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
onComplete,
|
||||
isFirstStep
|
||||
}) => {
|
||||
const wizardContext = useWizardContext();
|
||||
|
||||
// Check if user is enterprise tier for conditional labels
|
||||
const subscriptionTier = localStorage.getItem('subscription_tier');
|
||||
const isEnterprise = subscriptionTier === 'enterprise';
|
||||
|
||||
// Get business_model from wizard context's bakeryType
|
||||
const businessModel = getBakeryBusinessModel(wizardContext.state.bakeryType);
|
||||
|
||||
const [formData, setFormData] = useState<BakeryRegistration>({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -27,9 +49,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
phone: '',
|
||||
city: 'Madrid',
|
||||
business_type: 'bakery',
|
||||
business_model: 'individual_bakery'
|
||||
business_model: businessModel
|
||||
});
|
||||
|
||||
// Update business_model when bakeryType changes in context
|
||||
React.useEffect(() => {
|
||||
const newBusinessModel = getBakeryBusinessModel(wizardContext.state.bakeryType);
|
||||
if (newBusinessModel !== formData.business_model) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
business_model: newBusinessModel
|
||||
}));
|
||||
}
|
||||
}, [wizardContext.state.bakeryType, formData.business_model]);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const registerBakery = useRegisterBakery();
|
||||
|
||||
@@ -110,6 +143,12 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📝 Registering tenant with data:', {
|
||||
bakeryType: wizardContext.state.bakeryType,
|
||||
business_model: formData.business_model,
|
||||
formData
|
||||
});
|
||||
|
||||
try {
|
||||
const tenant = await registerBakery.mutateAsync(formData);
|
||||
|
||||
@@ -167,8 +206,8 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
|
||||
<Input
|
||||
label="Nombre de la Panadería"
|
||||
placeholder="Ingresa el nombre de tu panadería"
|
||||
label={isEnterprise ? "Nombre del Obrador Central" : "Nombre de la Panadería"}
|
||||
placeholder={isEnterprise ? "Ingresa el nombre de tu obrador central" : "Ingresa el nombre de tu panadería"}
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
error={errors.name}
|
||||
@@ -191,7 +230,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
</label>
|
||||
<AddressAutocomplete
|
||||
value={formData.address}
|
||||
placeholder="Enter bakery address..."
|
||||
placeholder={isEnterprise ? "Dirección del obrador central..." : "Dirección de tu panadería..."}
|
||||
onAddressSelect={(address) => {
|
||||
console.log('Selected:', address.display_name);
|
||||
handleAddressSelect(address);
|
||||
@@ -236,10 +275,10 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
isLoading={registerBakery.isPending}
|
||||
loadingText="Registrando..."
|
||||
loadingText={isEnterprise ? "Registrando obrador..." : "Registrando..."}
|
||||
size="lg"
|
||||
>
|
||||
Crear Panadería y Continuar
|
||||
{isEnterprise ? "Crear Obrador Central y Continuar" : "Crear Panadería y Continuar"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
|
||||
// Core Onboarding Steps
|
||||
export { RegisterTenantStep } from './RegisterTenantStep';
|
||||
export { POIDetectionStep } from './POIDetectionStep';
|
||||
export { ChildTenantsSetupStep } from './ChildTenantsSetupStep';
|
||||
|
||||
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
|
||||
export { FileUploadStep } from './FileUploadStep';
|
||||
@@ -17,9 +18,6 @@ export { UploadSalesDataStep } from './UploadSalesDataStep';
|
||||
export { default as ProductCategorizationStep } from './ProductCategorizationStep';
|
||||
export { default as InitialStockEntryStep } from './InitialStockEntryStep';
|
||||
|
||||
// Production Steps
|
||||
export { default as ProductionProcessesStep } from './ProductionProcessesStep';
|
||||
|
||||
// ML & Finalization
|
||||
export { MLTrainingStep } from './MLTrainingStep';
|
||||
export { CompletionStep } from './CompletionStep';
|
||||
@@ -32,7 +32,7 @@ export const StepProgress: React.FC<StepProgressProps> = ({
|
||||
</div>
|
||||
<div className="text-center sm:text-right">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('setup_wizard:progress.step_of', 'Step {{current}} of {{total}}', {
|
||||
{t('setup_wizard:progress.step_of', 'Step {current} of {total}', {
|
||||
current: currentStepIndex + 1,
|
||||
total: steps.length
|
||||
})}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo';
|
||||
import { destroyDemoSession } from '../../../api/services/demo';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDemoTour, getTourState, trackTourEvent } from '../../../features/demo-onboarding';
|
||||
import { BookOpen, Clock, Sparkles, X } from 'lucide-react';
|
||||
import { BookOpen, Check, Clock, Sparkles, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const DemoBanner: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('demo');
|
||||
const { startTour, resumeTour } = useDemoTour();
|
||||
const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true');
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at'));
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||
const [canExtend, setCanExtend] = useState(true);
|
||||
const [extending, setExtending] = useState(false);
|
||||
const [showExitModal, setShowExitModal] = useState(false);
|
||||
|
||||
// Memoize tour state to prevent re-renders
|
||||
@@ -42,7 +42,7 @@ export const DemoBanner: React.FC = () => {
|
||||
const diff = expiryTime - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeRemaining('Sesión expirada');
|
||||
setTimeRemaining(t('banner.session_expired', 'Sesión expirada'));
|
||||
await handleExpiration();
|
||||
} else {
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
@@ -81,27 +81,6 @@ export const DemoBanner: React.FC = () => {
|
||||
navigate('/demo');
|
||||
};
|
||||
|
||||
const handleExtendSession = async () => {
|
||||
const sessionId = apiClient.getDemoSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
setExtending(true);
|
||||
try {
|
||||
const updatedSession = await extendDemoSession({ session_id: sessionId });
|
||||
localStorage.setItem('demo_expires_at', updatedSession.expires_at);
|
||||
setExpiresAt(updatedSession.expires_at);
|
||||
|
||||
if (updatedSession.remaining_extensions === 0) {
|
||||
setCanExtend(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extending session:', error);
|
||||
alert('No se pudo extender la sesión');
|
||||
} finally {
|
||||
setExtending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndSession = async () => {
|
||||
setShowExitModal(true);
|
||||
};
|
||||
@@ -153,7 +132,7 @@ export const DemoBanner: React.FC = () => {
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<span className="font-bold text-base">Sesión Demo Activa</span>
|
||||
<span className="font-bold text-base">{t('banner.active_session')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm bg-white/20 rounded-md px-3 py-1">
|
||||
@@ -163,7 +142,7 @@ export const DemoBanner: React.FC = () => {
|
||||
|
||||
{tourState && !tourState.completed && tourState.currentStep > 0 && (
|
||||
<div className="hidden md:flex items-center gap-2 text-sm bg-white/20 rounded-md px-3 py-1">
|
||||
<span>Tutorial pausado en paso {tourState.currentStep}</span>
|
||||
<span>{t('banner.tutorial_paused')} {tourState.currentStep}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,10 +157,14 @@ export const DemoBanner: React.FC = () => {
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{tourState && tourState.currentStep > 0 && !tourState.completed
|
||||
? 'Continuar Tutorial'
|
||||
: 'Ver Tutorial'}
|
||||
? t('banner.continue_tutorial')
|
||||
: t('banner.tutorial_button')}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{tourState && tourState.currentStep > 0 && !tourState.completed
|
||||
? t('banner.continue_tutorial')
|
||||
: t('banner.tutorial_button')}
|
||||
</span>
|
||||
<span className="sm:hidden">Tutorial</span>
|
||||
</button>
|
||||
|
||||
{/* Create account CTA */}
|
||||
@@ -190,27 +173,15 @@ export const DemoBanner: React.FC = () => {
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white text-orange-600 rounded-lg text-sm font-bold hover:bg-orange-50 transition-all shadow-sm hover:shadow-md animate-pulse hover:animate-none"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">¡Crear Cuenta Gratis!</span>
|
||||
<span className="lg:hidden">Crear Cuenta</span>
|
||||
<span>{t('banner.create_account')}</span>
|
||||
</button>
|
||||
|
||||
{/* Extend session */}
|
||||
{canExtend && (
|
||||
<button
|
||||
onClick={handleExtendSession}
|
||||
disabled={extending}
|
||||
className="px-3 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 hidden sm:block"
|
||||
>
|
||||
{extending ? 'Extendiendo...' : '+30 min'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* End session */}
|
||||
<button
|
||||
onClick={handleEndSession}
|
||||
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<span className="hidden sm:inline">Salir</span>
|
||||
<span className="hidden sm:inline">{t('banner.exit')}</span>
|
||||
<X className="w-4 h-4 sm:hidden" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -228,26 +199,39 @@ export const DemoBanner: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">
|
||||
¿Seguro que quieres salir?
|
||||
{t('exit_modal.title')}
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] text-sm leading-relaxed">
|
||||
Aún te quedan <span className="font-bold text-amber-600">{timeRemaining}</span> de sesión demo.
|
||||
{t('exit_modal.time_left')} <span className="font-bold text-amber-600">{timeRemaining}</span> {t('exit_modal.time_left_suffix')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 rounded-xl p-4 mb-6 border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
¿Te gusta lo que ves?
|
||||
</p>
|
||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 rounded-xl p-5 mb-6 border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-base font-bold text-[var(--text-primary)]">
|
||||
{t('exit_modal.cta_title')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4 leading-relaxed">
|
||||
Crea una cuenta <span className="font-bold">gratuita</span> para acceder a todas las funcionalidades sin límites de tiempo y guardar tus datos de forma permanente.
|
||||
{t('exit_modal.cta_description')}
|
||||
</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--text-primary)]">
|
||||
<Check className="w-4 h-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<span><span className="font-semibold">{t('exit_modal.benefits.save_data')}</span> {t('exit_modal.benefits.save_data_suffix')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--text-primary)]">
|
||||
<Check className="w-4 h-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<span><span className="font-semibold">{t('exit_modal.benefits.full_access')}</span> {t('exit_modal.benefits.full_access_suffix')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateAccount}
|
||||
className="w-full py-3 bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-lg font-bold hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
Crear Mi Cuenta Gratis
|
||||
{t('exit_modal.create_account_button')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -256,13 +240,13 @@ export const DemoBanner: React.FC = () => {
|
||||
onClick={() => setShowExitModal(false)}
|
||||
className="flex-1 px-4 py-2.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)] rounded-lg font-semibold transition-colors border border-[var(--border-default)]"
|
||||
>
|
||||
Seguir en Demo
|
||||
{t('exit_modal.continue_demo')}
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmEndSession}
|
||||
className="flex-1 px-4 py-2.5 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 rounded-lg font-semibold transition-colors border border-red-200 dark:border-red-800"
|
||||
>
|
||||
Salir de Demo
|
||||
{t('exit_modal.exit_demo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user