IMPORVE ONBOARDING STEPS

This commit is contained in:
Urtzi Alfaro
2025-11-09 09:22:08 +01:00
parent 4678f96f8f
commit cbe19a3cd1
27 changed files with 2801 additions and 1149 deletions

877
ARCHITECTURE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,877 @@
# Comprehensive Architecture Analysis: UnifiedOnboardingWizard
## Executive Summary
The UnifiedOnboardingWizard is a 14-step onboarding flow that has evolved to integrate multiple concerns (file upload, inventory management, product categorization, stock entry, ML training) into a single, monolithic UploadSalesDataStep component. While the overall wizard architecture is sound, there is significant technical debt in the step implementations, particularly in UploadSalesDataStep which mixes file upload, inventory management, and state management responsibilities.
---
## 1. CURRENT ARCHITECTURE OVERVIEW
### 1.1 Wizard Structure
**Location**: `/frontend/src/components/domain/onboarding/`
The wizard consists of:
- **UnifiedOnboardingWizard.tsx** - Main wizard orchestrator (533 lines)
- **WizardContext.tsx** - State management (277 lines)
- **OnboardingWizard.tsx** - Deprecated (567 lines - SHOULD BE DELETED)
- **14 individual step components** (ranging from 736 to 74,627 lines)
### 1.2 Visible Steps Flow
The wizard displays 14 steps in this order:
```
Phase 1: Discovery
├─ 1. bakery-type-selection (BakeryTypeSelectionStep)
└─ 2. setup (RegisterTenantStep)
Phase 2a: AI-Assisted Inventory Path
├─ 3. smart-inventory-setup (UploadSalesDataStep) ⚠️ MEGA-COMPONENT
├─ 4. product-categorization (ProductCategorizationStep)
├─ 5. initial-stock-entry (InitialStockEntryStep)
└─ (Auto-completes: suppliers-setup)
Phase 2b: Setup Operations
├─ 6. suppliers-setup (SuppliersSetupStep)
├─ 7. recipes-setup (RecipesSetupStep - conditional)
├─ 8. production-processes (ProductionProcessesStep - conditional)
Phase 3: Advanced Features
├─ 9. quality-setup (QualitySetupStep)
└─ 10. team-setup (TeamSetupStep)
Phase 4: ML & Finalization
├─ 11. ml-training (MLTrainingStep)
├─ 12. setup-review (ReviewSetupStep)
└─ 13. completion (CompletionStep)
```
**Note**: Step numbering is complex because:
- Steps 1-13 are in UnifiedOnboardingWizard
- SetupWizard (deprecated) has separate steps (setup-welcome, suppliers-setup, etc.)
- Conditional visibility based on bakeryType
- Auto-completion logic for suppliers-setup
---
## 2. DETAILED ARCHITECTURAL ISSUES
### 2.1 ISSUE #1: UploadSalesDataStep is a Mega-Component (74,627 lines)
**Severity**: CRITICAL
**Current Responsibilities**:
1. File upload & validation (lines 1-169)
2. Automatic file analysis with AI (lines 141-169)
3. AI-based product classification (lines 171-247)
4. Inventory item form management (lines 249-433)
5. Stock lot management (lines 335-435)
6. Batch operations (lines 437-589)
7. Sales data import (lines 548-569)
8. UI rendering for two distinct phases:
- File upload phase (lines 1408-1575)
- Inventory list/editing phase (lines 612-1405)
9. Modal integration (BatchAddIngredientsModal)
**Problems**:
- Violates Single Responsibility Principle
- Hard to test (>1500 lines in single component)
- Multiple state variables (23 useState calls)
- Tightly couples file upload with inventory management
- Complex conditional rendering logic
- Difficult to reuse any sub-functionality
**Code Indicators**:
```typescript
// Line 22-114: THREE separate form interfaces and states
interface ProgressState { ... }
interface InventoryItemForm { ... }
// Plus: [selectedFile, isValidating, validationResult, inventoryItems, ...]
// TOTAL: 23+ state variables
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState<ImportValidationResponse | null>(null);
// ... 20 more state declarations
```
### 2.2 ISSUE #2: Separated File Upload & Inventory Concerns
**Severity**: HIGH
The UploadSalesDataStep handles TWO distinct user journeys:
**Journey 1: File Upload & Auto-Classification**
```
User uploads CSV
Validate file (validateFileMutation)
Classify products (classifyBatchMutation)
Generate AI suggestions
Show inventory list for review
```
**Journey 2: Manual Inventory Entry**
```
After AI suggestions generated
Edit/add inventory items manually
Add stock lots per item
Create all ingredients via API (createIngredient)
Create all stock lots (addStockMutation)
Import sales data
```
**Problem**: Both journeys are in ONE component with complex conditional rendering:
```typescript
// Line 613: Conditional branch for entire second phase
if (showInventoryStep) {
// 800+ lines of inventory management UI
return (<div className="space-y-6"> ... </div>);
}
// Line 1408: Final 167 lines for file upload UI
return (<div className="space-y-6"> ... </div>);
```
### 2.3 ISSUE #3: State Management Fragmentation
**Severity**: MEDIUM
**Local Component State (UploadSalesDataStep)**:
- File selection: `selectedFile`, `isValidating`, `validationResult`
- Inventory: `inventoryItems`, `showInventoryStep`, `error`, `progressState`
- Form data: `formData`, `editingId`, `isAdding`, `formErrors`
- Stock lots: `addingStockForId`, `stockFormData`, `stockErrors`, `ingredientStocks`
**Wizard Context State** (WizardContext.tsx):
- `bakeryType`, `dataSource`, `uploadedFileName`, `uploadedFileSize`
- `aiSuggestions`, `aiAnalysisComplete`, `categorizedProducts`, `productsWithStock`
- Various completion flags: `categorizationCompleted`, `stockEntryCompleted`, etc.
**Backend Progress State** (API):
- `useUserProgress()` fetches current step, completed steps, completion percentage
- `useMarkStepCompleted()` updates backend after step completion
**Problem**: No clear separation of concerns
- Wizard context is "source of truth" but individual steps also have local state
- UI state (isAdding, editingId) mixed with data state (inventoryItems)
- No clear data flow: Step data → Wizard context → Backend
- Difficult to understand which data persists across sessions
### 2.4 ISSUE #4: Circular State Dependencies
**Severity**: MEDIUM
**UploadSalesDataStep → Wizard Context**:
```typescript
// Line 320: Updates wizard context on completion
wizardContext.updateAISuggestions(data.aiSuggestions);
wizardContext.setAIAnalysisComplete(true);
// Line 325:
wizardContext.updateCategorizedProducts(data.categorizedProducts);
```
**ProductCategorizationStep → Wizard Context**:
```typescript
// Receives products from wizard context
// Updates categorizedProducts in wizard context
onUpdate?.({ categorizedProducts: updatedProducts });
```
**InitialStockEntryStep → Wizard Context**:
```typescript
// Receives products from categorization
// Updates productsWithStock in wizard context
onUpdate?.({ productsWithStock: updatedProducts });
```
**Problem**: Steps update wizard context via `onUpdate()` AND `onComplete()`
- Data flows both ways (child → parent AND parent → child)
- Difficult to trace data transformations
- No clear contract between parent and child
### 2.5 ISSUE #5: Tight Coupling Between Wizard & Setup Components
**Severity**: HIGH
**UnifiedOnboardingWizard mixes:**
- 8 onboarding-specific steps from `/onboarding/steps/`
- 5 setup-specific steps from `/setup-wizard/steps/`
**Import at line 22-28**:
```typescript
import {
SuppliersSetupStep,
RecipesSetupStep,
QualitySetupStep,
TeamSetupStep,
ReviewSetupStep,
} from '../setup-wizard/steps';
```
**Problem**:
- UnifiedOnboardingWizard shouldn't need setup-wizard imports
- Creates dependency between onboarding and setup domains
- Changes in setup steps affect onboarding flow
- Makes it harder to modify setup flow independently
- Violates layering/module boundaries
### 2.6 ISSUE #6: Auto-Completion Logic Scattered
**Severity**: MEDIUM
**Auto-completion of suppliers-setup** (line 349-365):
```typescript
// In UnifiedOnboardingWizard.handleStepComplete()
if (currentStep.id === 'smart-inventory-setup' && data?.shouldAutoCompleteSuppliers) {
try {
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: 'suppliers-setup',
data: { auto_completed: true, ... }
});
} catch (supplierError) {
console.warn('Could not auto-complete suppliers-setup step');
}
}
```
**Auto-completion of user_registered** (line 204-232):
```typescript
// In OnboardingWizardContent.useEffect()
useEffect(() => {
if (userProgress && user?.id && !autoCompletionAttempted && !markStepCompleted.isPending) {
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
if (!userRegisteredStep?.completed) {
// Auto-complete...
}
}
}, [...]);
```
**Problem**:
- Auto-completion logic is buried in step completion handler
- No clear policy for which steps can be auto-completed
- Difficult to add new auto-completion rules
- Inconsistent with manual step completion flow
### 2.7 ISSUE #7: Condition Functions Are Scattered & Duplicated
**Severity**: LOW-MEDIUM
**Step visibility conditions** (line 59-165):
```typescript
const ALL_STEPS: StepConfig[] = [
{
id: 'smart-inventory-setup',
condition: (ctx) => ctx.tenantId !== null,
},
{
id: 'product-categorization',
condition: (ctx) => ctx.state.aiAnalysisComplete,
},
{
id: 'initial-stock-entry',
condition: (ctx) => ctx.state.categorizationCompleted,
},
{
id: 'suppliers-setup',
// Always show - no conditional
},
{
id: 'recipes-setup',
condition: (ctx) =>
ctx.state.bakeryType === 'production' || ctx.state.bakeryType === 'mixed',
},
// ... more conditions
];
```
**Identical conditions in WizardContext** (line 183-189):
```typescript
getVisibleSteps = (): string[] => {
// ... same conditions repeated
if (state.bakeryType === 'production' || state.bakeryType === 'mixed') {
steps.push('recipes-setup');
}
if (state.bakeryType === 'retail' || state.bakeryType === 'mixed') {
steps.push('production-processes');
}
};
```
**Problem**:
- Same logic defined in TWO places
- Changes require updates in multiple files
- No single source of truth for visibility rules
- Conditions are business logic, should be defined separately
### 2.8 ISSUE #8: Complex Data Transformations Without Clear Contracts
**Severity**: MEDIUM**
**UploadSalesDataStep → ProductCategorizationStep**:
```typescript
// Line 203-235: UploadSalesDataStep generates InventoryItemForm[]
const items: InventoryItemForm[] = classificationResponse.suggestions.map((suggestion) => ({
id: `ai-${index}-${Date.now()}`,
name: suggestion.suggested_name,
// ... 15 more properties
}));
// Then completes with:
onComplete({
createdIngredients,
totalItems: createdIngredients.length,
validationResult,
// ... more data
});
// But ProductCategorizationStep expects Product[] (different interface!)
interface Product {
id: string;
name: string;
category?: string;
confidence?: number;
type?: 'ingredient' | 'finished_product' | null;
suggestedType?: 'ingredient' | 'finished_product';
}
```
**Problem**:
- No clear transformation between step outputs and next step inputs
- Multiple data formats for similar data
- Type mismatches not caught until runtime
- No schema/contract for inter-step communication
---
## 3. STEP DEPENDENCIES & DATA FLOW
### 3.1 Dependency Diagram
```
BakeryTypeSelectionStep
└─> wizardContext.updateBakeryType()
└─> affects visibility of: recipes-setup, production-processes
RegisterTenantStep
└─> wizardContext.tenantId (implicit - via tenant store)
└─> enables: smart-inventory-setup
UploadSalesDataStep [MEGA-COMPONENT]
├─> Validates file (validateFileMutation)
├─> Classifies products (classifyBatchMutation)
├─> Creates inventory (createIngredient) ← INVENTORY CONCERN
├─> Creates stock lots (addStockMutation) ← INVENTORY CONCERN
├─> Imports sales data (importMutation)
├─> wizardContext.updateAISuggestions()
├─> wizardContext.setAIAnalysisComplete()
├─> markStepCompleted() [auto-completes suppliers-setup] ← ORCHESTRATION
└─> onComplete() with shouldAutoCompleteSuppliers flag
ProductCategorizationStep
├─> Input: wizard context aiSuggestions or initialData.categorizedProducts
├─> onUpdate() → wizardContext.updateCategorizedProducts()
└─> onComplete()
└─> wizardContext.markStepComplete('categorizationCompleted')
InitialStockEntryStep
├─> Input: categorizedProducts (which type: ingredient or finished_product?)
├─> onUpdate() → wizardContext.updateProductsWithStock()
└─> onComplete()
└─> wizardContext.markStepComplete('stockEntryCompleted')
SuppliersSetupStep (from setup-wizard)
├─> May be auto-completed by UploadSalesDataStep
└─> Creates suppliers
RecipesSetupStep (conditional: production or mixed)
└─> Depends on suppliers existing
ProductionProcessesStep (conditional: retail or mixed)
└─> May depend on recipes?
MLTrainingStep
└─> Input: inventory data from previous steps
└─> No explicit dependency check
ReviewSetupStep
└─> Shows summary of configuration
CompletionStep
└─> Final step, navigates away
```
### 3.2 Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ UnifiedOnboardingWizard │
│ (Step Orchestrator) │
└─────────────────────────────────────────────────────────────────┘
↑ ↑
│ state (VISIBLE_STEPS, currentStepIndex) │ markStepCompleted()
│ │
┌────┴───────────────────────────────────────────────────┴──┐
│ WizardContext │
│ (State: bakeryType, aiSuggestions, categorizedProducts) │
│ (State: productsWithStock, various completion flags) │
└────┬───────────────────────────────────────────────────┬───┘
│ onUpdate() / onComplete() │
│ (data flows FROM steps TO context) │
│ │
┌────┴────────────────────────────────────────────────┬──┘
│ Individual Steps │
│ │
│ BakeryTypeSelectionStep │
│ RegisterTenantStep │
│ UploadSalesDataStep ⚠️ [73KB, 23 state vars] │
│ ├─ File validation │
│ ├─ AI classification │
│ ├─ Inventory creation │
│ └─ Stock lot management │
│ ProductCategorizationStep │
│ InitialStockEntryStep │
│ SuppliersSetupStep (from setup-wizard) │
│ ... │
└──────────────────────────────────────────────────────┘
│ │
│ │
API mutations Backend progress
(validateFile, classifyBatch, (useUserProgress,
createIngredient, addStock, useMarkStepCompleted)
importSalesData)
```
### 3.3 Data Transformation Pipeline
```
CSV/JSON File
[UploadSalesDataStep] validateFileMutation
↓ (ImportValidationResponse)
├─ total_records, valid_records, invalid_records
├─ product_list: string[]
└─ ...
[UploadSalesDataStep] classifyBatchMutation
↓ (ProductSuggestionResponse[])
├─ id, suggested_name, product_type
├─ category, unit_of_measure
├─ confidence_score
├─ estimated_shelf_life_days
├─ requires_refrigeration, requires_freezing, is_seasonal
├─ low_stock_threshold, reorder_point
└─ sales_data: { total_quantity, average_daily_sales }
[UploadSalesDataStep] Transform to InventoryItemForm[]
├─ id: string (temporary UI ID)
├─ name, product_type, category
├─ unit_of_measure
├─ stock_quantity (calculated from sales data)
├─ cost_per_unit (estimated)
├─ estimated_shelf_life_days
├─ requires_refrigeration, requires_freezing, is_seasonal
├─ low_stock_threshold, reorder_point
├─ isSuggested: boolean
├─ confidence_score
└─ sales_data: { total_quantity, average_daily_sales }
[UploadSalesDataStep] User review & manual edits
├─ Add/edit/delete inventory items
├─ Add stock lots (optional)
└─ ...
[UploadSalesDataStep] createIngredient mutations (parallel)
↓ (Ingredient[])
├─ id (from backend)
├─ name, product_type, category
├─ unit_of_measure, shelf_life_days
├─ requires_refrigeration, requires_freezing, is_seasonal
├─ low_stock_threshold, max_stock_level, reorder_point
└─ average_cost
[UploadSalesDataStep] addStockMutation (parallel)
↓ (StockResponse[])
├─ id (from backend)
├─ ingredient_id
├─ current_quantity
├─ expiration_date
├─ supplier_id
├─ batch_number
├─ production_stage
└─ quality_status
[UploadSalesDataStep] importMutation (sales data)
[ProductCategorizationStep] ← BUT RECEIVES: wizard context aiSuggestions
↓ (Product[] with type: 'ingredient' | 'finished_product')
[InitialStockEntryStep] ← Receives: categorizedProducts
↓ (ProductWithStock[] with initialStock)
[Backend] Final inventory saved
```
**Problem**: Data transformations are implicit and scattered across files. No clear schema/types for inter-step data passing.
---
## 4. LEGACY & DEPRECATED COMPONENTS
### 4.1 OnboardingWizard.tsx - DEPRECATED
**Location**: `/frontend/src/components/domain/onboarding/OnboardingWizard.tsx`
**Status**: DEPRECATED but still exported in index.ts
**Usage**:
```typescript
// frontend/src/components/domain/onboarding/index.ts
export { OnboardingWizard } from './OnboardingWizard'; EXPORTED BUT NOT USED
export { UnifiedOnboardingWizard } from './UnifiedOnboardingWizard'; THIS IS USED
```
**Confirmation of non-usage**:
- OnboardingPage.tsx uses only UnifiedOnboardingWizard
- No other imports of OnboardingWizard found in codebase
- Old wizard has simplified step flow (4 steps vs 14)
**Recommendation**: DELETE OnboardingWizard.tsx and remove from index.ts exports
### 4.2 SetupWizard.tsx - STILL IN USE
**Location**: `/frontend/src/components/domain/setup-wizard/SetupWizard.tsx`
**Status**: ACTIVE but DEPRECATED by design
**Usage**:
```typescript
// frontend/src/pages/setup/SetupPage.tsx
const SetupPage: React.FC = () => {
return <SetupWizard />;
};
```
**Problem**:
- UnifiedOnboardingWizard imports setup-wizard steps directly
- Creates tight coupling between onboarding and setup domains
- SetupWizard is now redundant - setup steps are integrated into UnifiedOnboardingWizard
- Separate SetupPage route still exists (/app/setup)
**Recommendation**:
1. Remove setup-wizard imports from UnifiedOnboardingWizard
2. Keep SetupWizard.tsx for backwards compatibility OR
3. Transition to new onboarding flow only and deprecate SetupPage
### 4.3 Route Duplication
**Current Routes**:
- `/app/onboarding` → OnboardingPage → UnifiedOnboardingWizard ✓ CORRECT
- `/app/setup` → SetupPage → SetupWizard ✓ LEGACY (now integrated into onboarding)
**Recommendation**:
- Remove /app/setup route OR
- Make it redirect to /app/onboarding for backwards compatibility
---
## 5. FILES TO DELETE
| File | Status | Reason |
|------|--------|--------|
| `/frontend/src/components/domain/onboarding/OnboardingWizard.tsx` | DELETE | Fully deprecated, non-functional |
| `/frontend/src/pages/setup/SetupPage.tsx` | DELETE or REDIRECT | Setup flow now integrated into onboarding |
| Setup wizard route entry in routes.config.ts | REMOVE | Duplicate with onboarding |
---
## 6. ARCHITECTURAL PROBLEMS SUMMARY
### Problem #1: MEGA-COMPONENT (UploadSalesDataStep)
- **Type**: Single Responsibility Principle Violation
- **Severity**: CRITICAL
- **Impact**: Hard to test, maintain, and extend
- **Solution**: Split into:
- FileUploadPhase (controlled by parent)
- InventoryManagementPhase (separate component or reusable)
- AI classification logic (service)
### Problem #2: State Management Fragmentation
- **Type**: State Distribution Anti-Pattern
- **Severity**: HIGH
- **Impact**: Difficult to trace data flow, understand state transitions
- **Solution**:
- Clear separation: UI state (local) vs Data state (context)
- Define explicit data contracts between steps
- Consider step output → context mapping layer
### Problem #3: Mixed Concerns in One Component
- **Type**: Separation of Concerns Violation
- **Severity**: HIGH
- **Impact**: Tight coupling, difficult to test individual features
- **Solution**:
- File upload logic → FileUploadService
- Inventory form logic → InventoryFormComponent
- Stock lot logic → StockLotComponent
- Composition pattern to combine them
### Problem #4: Duplicate Visibility Logic
- **Type**: DRY Principle Violation
- **Severity**: MEDIUM
- **Impact**: Maintenance burden, potential inconsistencies
- **Solution**:
- Extract visibility rules into dedicated config/service
- Single source of truth for step conditions
### Problem #5: Circular Data Dependencies
- **Type**: Bidirectional Data Flow Anti-Pattern
- **Severity**: MEDIUM
- **Impact**: Hard to understand data flow direction
- **Solution**:
- Strict unidirectional data flow (parent → child via props, child → parent via callbacks)
- Clear input/output contracts for each step
### Problem #6: Tight Coupling: Onboarding ↔ Setup Wizard
- **Type**: Module Boundary Violation
- **Severity**: HIGH
- **Impact**: Difficult to modify either wizard independently
- **Solution**:
- Remove setup-wizard imports from UnifiedOnboardingWizard
- Define step interface contract
- Load steps dynamically by configuration
### Problem #7: Complex Auto-Completion Logic
- **Type**: Business Logic Distribution
- **Severity**: MEDIUM
- **Impact**: Difficult to understand which steps auto-complete
- **Solution**:
- Centralized auto-completion policy
- Explicit configuration for which steps can auto-complete
- Clear rules for completion dependencies
### Problem #8: No Clear Inter-Step Communication Contract
- **Type**: Interface Definition Anti-Pattern
- **Severity**: MEDIUM
- **Impact**: Type mismatches between steps (InventoryItemForm vs Product vs ProductWithStock)
- **Solution**:
- Define Step Interface Contract
- Create transformation layer between step outputs
- Use consistent naming/typing across all steps
---
## 7. STEP RESPONSIBILITIES MATRIX
| Step | Primary Responsibility | State Type | Dependencies | Outputs |
|------|------------------------|-----------|--------------|---------|
| BakeryTypeSelectionStep | Select bakery model | UI | None | bakeryType |
| RegisterTenantStep | Register bakery details | API call | bakeryType | tenantId, tenant object |
| **UploadSalesDataStep** | **File upload + Inventory mgmt + Stock lots** | **API calls + Local forms** | **tenantId** | **Ingredients[] + Stock[] + Sales import result** |
| ProductCategorizationStep | Classify products | Drag-drop state | aiSuggestions | categorizedProducts |
| InitialStockEntryStep | Set initial stock levels | Form state | categorizedProducts | productsWithStock |
| SuppliersSetupStep | Add suppliers | API + Form | tenantId | suppliers (or auto-completed) |
| RecipesSetupStep | Create recipes | API + Complex forms | ingredients + suppliers | recipes |
| ProductionProcessesStep | Define processes | API + Complex forms | bakeryType | processes |
| QualitySetupStep | Set quality standards | API + Forms | tenantId | quality templates |
| TeamSetupStep | Add team members | API + Forms | tenantId | team members |
| MLTrainingStep | Train ML models | API poll + long-running | inventory data | trained models |
| ReviewSetupStep | Show configuration summary | Read-only | All previous data | (none, informational) |
| CompletionStep | Celebrate + redirect | Navigation | None | Navigate to dashboard |
---
## 8. RECOMMENDATIONS FOR REFACTORING
### Immediate Actions (Quick Wins)
1. **Delete OnboardingWizard.tsx**
- Remove from exports in index.ts
- Impact: LOW (not used)
- Time: 5 minutes
2. **Remove setup-wizard imports from UnifiedOnboardingWizard**
- Load setup steps dynamically
- Create step registry/configuration
- Impact: MEDIUM (refactoring pattern needed)
- Time: 2-3 hours
3. **Extract visibility rules to dedicated config**
- Move bakeryType conditions to one place
- Impact: LOW (pure refactoring)
- Time: 1 hour
### Medium-term Refactoring (1-2 Sprint)
4. **Split UploadSalesDataStep**
- Extract FileUploadPhase (lines 1408-1575)
- Extract InventoryManagementPhase (lines 612-1405)
- Create composition layer
- Move AI classification to service
- Impact: HIGH (major refactoring)
- Time: 6-8 hours
5. **Define Inter-Step Data Contracts**
- Create interfaces for step input/output
- Add transformation layer between steps
- Impact: HIGH (enforces consistency)
- Time: 4-6 hours
6. **Separate UI State from Data State in WizardContext**
- Move isAdding, editingId to component state
- Keep only data state in context
- Impact: MEDIUM (refactoring)
- Time: 3-4 hours
### Long-term Architecture (2-3 Months)
7. **Implement Step Interface Contract**
- Define standard step props/callback signatures
- Enforce typing at wizard level
- Impact: HIGH (system-wide improvement)
- Time: 1-2 sprints
8. **Extract Auto-Completion Policy Engine**
- Centralized configuration for auto-completion
- Clear rules for step dependencies
- Impact: MEDIUM (optional feature)
- Time: 1 sprint
9. **Consolidate Setup & Onboarding Routes**
- Make /app/setup redirect to /app/onboarding
- OR keep SetupPage for post-onboarding scenarios
- Impact: LOW-MEDIUM (routing refactoring)
- Time: 2-3 hours
---
## 9. CURRENT STEP-BY-STEP FLOW DIAGRAM
```
┌───────────────────────────────────────────────────────────┐
│ USER FLOW: UnifiedOnboardingWizard (14 Steps) │
└───────────────────────────────────────────────────────────┘
START: User completes registration
↓ Auto-completes user_registered step
STEP 1: Bakery Type Selection
└─> SELECT: production | retail | mixed
└─> STORES: bakeryType in context
STEP 2: Register Tenant
└─> ENTER: Bakery name, address, phone, city
└─> API: registerBakery()
└─> STORES: tenantId, tenant object
STEP 3: Upload Sales Data (⚠️ MEGA-COMPONENT)
├─> UPLOAD: CSV/JSON file with sales history
│ └─> API: validateFile()
│ └─> API: classifyBatch() [AI classification]
│ └─> DISPLAY: Suggested inventory items
└─> REVIEW & EDIT: Inventory items
├─ ALLOW: Add, edit, delete items
├─ ALLOW: Add stock lots per item
│ └─> FIELDS: Quantity, expiration date, supplier, batch number
└─> API: createIngredient() [parallel for all items]
└─> API: addStock() [parallel for all stock lots]
└─> API: importSalesData() [background import]
└─> Auto-complete: suppliers-setup step
STEP 4: Product Categorization
├─> DISPLAY: Suggested AI classifications
├─> ALLOW: Drag-drop to categorize as ingredient or finished_product
└─> STORES: categorizedProducts in context
STEP 5: Initial Stock Entry
├─> DISPLAY: All products from categorization
├─> ALLOW: Enter initial stock quantities
└─> STORES: productsWithStock in context
STEP 6: Suppliers Setup ⭐ (May be auto-completed)
├─> IF auto-completed: Display "✓ Auto-setup"
└─> ELSE: ALLOW: Add suppliers (company, contact, details)
└─> API: createSupplier()
STEP 7: Recipes Setup (CONDITIONAL: production or mixed bakeries)
├─> ALLOW: Create recipes with ingredients
├─> INPUT: Select ingredients from inventory
├─> INPUT: Set quantities, proportions
└─> API: createRecipe()
STEP 8: Production Processes (CONDITIONAL: retail or mixed bakeries)
├─> ALLOW: Define production workflows
└─> API: createProcess()
STEP 9: Quality Setup
├─> ALLOW: Define quality templates/standards
└─> API: createQualityTemplate()
STEP 10: Team Setup
├─> ALLOW: Add team members, assign roles
└─> API: inviteUser()
STEP 11: ML Training
├─> INFO: "Training personalized AI model..."
├─> DISPLAY: Progress indicator
├─> WAIT: For training job completion (API poll)
└─> DISPLAY: Model accuracy metrics
STEP 12: Setup Review
├─> DISPLAY: Summary of all configuration
└─> ALLOW: Review before finalizing
STEP 13: Completion
└─> DISPLAY: "✓ Setup complete!"
└─> REDIRECT: /app/dashboard
END: User is now in the main application
```
---
## 10. SUMMARY TABLE
| Aspect | Status | Priority | Notes |
|--------|--------|----------|-------|
| Overall Architecture | FUNCTIONAL | - | Works but has technical debt |
| UnifiedOnboardingWizard Main | GOOD | LOW | Well-structured orchestrator |
| WizardContext | ACCEPTABLE | MEDIUM | State management needs clarification |
| UploadSalesDataStep | POOR | CRITICAL | 74KB mega-component needs refactoring |
| OnboardingWizard (old) | DEPRECATED | HIGH | Should be deleted |
| SetupWizard Integration | PROBLEMATIC | HIGH | Tight coupling needs fixing |
| Visibility Rules | DUPLICATE | MEDIUM | DRY principle violation |
| Inter-Step Contracts | UNDEFINED | MEDIUM | No formal interface definition |
| Auto-Completion | AD-HOC | MEDIUM | Needs centralized policy |
| State Management | FRAGMENTED | MEDIUM | Mixing UI and data state |
---
## APPENDIX: FILE STATISTICS
| File | Size | LOC | Complexity | Status |
|------|------|-----|-----------|--------|
| UnifiedOnboardingWizard.tsx | 20.5 KB | 533 | MEDIUM | GOOD |
| WizardContext.tsx | 9.5 KB | 277 | MEDIUM | ACCEPTABLE |
| OnboardingWizard.tsx | 19.5 KB | 567 | MEDIUM | DEPRECATED |
| UploadSalesDataStep.tsx | 74.6 KB | 1577 | HIGH | POOR |
| ProductCategorizationStep.tsx | 14.3 KB | 365 | MEDIUM | ACCEPTABLE |
| InitialStockEntryStep.tsx | 11.2 KB | 286 | LOW | GOOD |
| BakeryTypeSelectionStep.tsx | 11 KB | 277 | MEDIUM | GOOD |
| RegisterTenantStep.tsx | 7.8 KB | ~200 | LOW | GOOD |
| SetupWizard.tsx | ~30 KB | ~800 | MEDIUM | ACTIVE |
| **TOTAL ONBOARDING** | **~169 KB** | **~3665** | - | - |
---

315
REFACTORING_ROADMAP.md Normal file
View File

@@ -0,0 +1,315 @@
# UnifiedOnboardingWizard Refactoring Roadmap
## Quick Executive Summary
**Current State**: Functional but architecturally problematic
**Main Issue**: UploadSalesDataStep is a 74KB mega-component mixing 8+ different concerns
**Debt Level**: HIGH - Technical debt is impacting maintainability
---
## Critical Issues (Must Fix)
### 1. UploadSalesDataStep is Too Large (CRITICAL)
- **File Size**: 74,627 bytes (73KB)
- **Lines**: 1,577 lines
- **State Variables**: 23 useState hooks
- **Methods**: Handles file upload, validation, classification, inventory management, stock lots, and more
- **Impact**: Almost impossible to test, modify, or reuse components
**What it should do**:
- Single responsibility: Upload and validate file
**What it actually does**:
1. File upload UI and drag-drop
2. File validation API call
3. AI classification API call
4. Inventory item form management (CRUD)
5. Stock lot management (CRUD)
6. Batch ingredient creation
7. Sales data import
8. Two entirely different UIs (upload phase vs inventory review phase)
**Refactoring Approach**:
```
Current:
UploadSalesDataStep (1577 lines, 23 state vars)
Should be:
├─ FileUploadPhase (200 lines)
│ ├─ FileDropZone
│ ├─ FileValidator
│ └─ ProgressIndicator
├─ InventoryManagementPhase (500 lines)
│ ├─ InventoryItemList
│ ├─ InventoryItemForm
│ ├─ StockLotManager
│ └─ BatchAddModal
├─ InventoryService (classification, validation)
└─ UploadSalesDataStepContainer (orchestrator, 150 lines)
├─ Phase state machine
├─ Data flow coordination
└─ API integration
```
### 2. Tight Coupling Between Onboarding & Setup (CRITICAL)
- UnifiedOnboardingWizard imports from setup-wizard/steps/
- Changes to setup steps affect onboarding flow
- Makes it impossible to modify setup independently
**Current Problem**:
```typescript
// UnifiedOnboardingWizard.tsx line 22-28
import {
SuppliersSetupStep,
RecipesSetupStep,
QualitySetupStep,
TeamSetupStep,
ReviewSetupStep,
} from '../setup-wizard/steps'; // ← CROSS-DOMAIN IMPORT
```
**Solution**:
- Define step interface contract
- Move setup steps to configuration
- Load steps dynamically
- Create step registry pattern
### 3. Circular State Dependencies (HIGH)
- Steps update wizard context via both onUpdate() and onComplete()
- Data flows both directions
- No clear source of truth
**Current Flow**:
```
UploadSalesDataStep.onUpdate() → wizardContext
ProductCategorizationStep.onUpdate() → wizardContext
InitialStockEntryStep.onUpdate() → wizardContext
All steps also call onComplete() with different data
```
**Should Be**:
```
Step.onUpdate() → Local UI state (transient)
Step.onComplete() → Single source of truth (wizardContext + Backend)
```
---
## High Priority Issues (Fix in Next Sprint)
### 4. Duplicate Visibility Logic
**Locations**:
- UnifiedOnboardingWizard.tsx (line 59-165): Step conditions
- WizardContext.tsx (line 183-189): getVisibleSteps() logic
- Same rules repeated in two places
**Fix**: Extract to single configuration file
### 5. Auto-Completion Logic is Scattered
- user_registered auto-completion: OnboardingWizardContent.useEffect() (line 204)
- suppliers-setup auto-completion: UnifiedOnboardingWizard.handleStepComplete() (line 349)
- No clear policy or pattern
**Fix**: Centralized auto-completion policy engine
### 6. State Management Confusion
- UI state mixed with data state in context
- No clear boundary between component state and context state
- Difficult to understand data persistence
**Fix**:
- UI state (isAdding, editingId, showInventoryStep) → Component local state
- Data state (aiSuggestions, categorizedProducts) → Context
- Session state → Backend API
---
## Medium Priority Issues (Fix Over Time)
### 7. No Inter-Step Communication Contract
**Problem**: Different components use different interfaces for same data
- InventoryItemForm (line 28-50)
- Product (ProductCategorizationStep line 7-14)
- ProductWithStock (InitialStockEntryStep line 8-15)
- Ingredient (API type)
- Stock (API type)
All represent similar data but with different shapes.
**Fix**: Create unified type system for inventory throughout flow
### 8. Complex Data Transformations Without Clear Schema
Data travels through pipeline with implicit transformations:
```
File → ImportValidationResponse → ProductSuggestionResponse
→ InventoryItemForm → Ingredient → Stock → categorizedProducts
→ Product (different type!) → ProductWithStock
```
**Fix**: Create explicit transformation functions between steps
---
## Files to Delete (Immediate)
1. **OnboardingWizard.tsx** (567 lines, 19.5KB)
- Status: Deprecated, not used
- Usage: Only in exports, never imported
- Risk: LOW - completely safe to delete
- Action: DELETE and remove from index.ts exports
2. **SetupPage.tsx** (18 lines, 567 bytes) - OPTIONAL
- Status: Deprecated by design
- Usage: Route /app/setup still references it
- Risk: MEDIUM - needs migration plan
- Action: Either delete or redirect to /app/onboarding
3. **Setup route in routes.config.ts** (line 578-593) - OPTIONAL
- Status: Redundant with /app/onboarding
- Action: Remove or convert to redirect
---
## Refactoring Priority & Timeline
### Phase 1: Quick Wins (Week 1)
**Time**: 2-3 hours
**Impact**: LOW immediate, HIGH long-term
- [ ] Delete OnboardingWizard.tsx
- [ ] Remove from index.ts exports
- [ ] Extract visibility rules to config file
- [ ] Add comments documenting current state
### Phase 2: State Management Cleanup (Week 2)
**Time**: 3-4 hours
**Impact**: MEDIUM
- [ ] Document which state belongs where (UI vs Context vs Backend)
- [ ] Move UI state out of context (isAdding, editingId, etc.)
- [ ] Clear data flow direction (parent → child → parent only)
### Phase 3: Split UploadSalesDataStep (Sprint)
**Time**: 6-8 hours
**Impact**: CRITICAL
- [ ] Extract FileUploadPhase component
- [ ] Extract InventoryManagementPhase component
- [ ] Extract classification logic to service
- [ ] Create container component to orchestrate
- [ ] Add unit tests for each extracted component
### Phase 4: Decouple Onboarding from Setup (Sprint)
**Time**: 4-6 hours
**Impact**: HIGH
- [ ] Define step interface contract
- [ ] Create step registry/configuration
- [ ] Move setup steps to configuration
- [ ] Load steps dynamically
### Phase 5: Inter-Step Communication (Sprint)
**Time**: 4-6 hours
**Impact**: MEDIUM
- [ ] Define unified inventory type system
- [ ] Create transformation functions between steps
- [ ] Add type validation at step boundaries
- [ ] Document data contracts
---
## Current Architecture Summary
### Strengths
1. **Main Wizard Orchestrator is Well-Designed**
- Clean step navigation
- Good progress tracking
- Proper initialization logic
- Clear completion flow
2. **WizardContext is Reasonable**
- Good use of React Context API
- Most step data stored centrally
- Safe from localStorage corruption
3. **Most Individual Steps are Well-Designed**
- ProductCategorizationStep: Good UI/UX
- InitialStockEntryStep: Simple and effective
- BakeryTypeSelectionStep: Beautiful design
### Weaknesses
1. **UploadSalesDataStep is a Monster**
- Mixes file upload + inventory management
- Too many state variables
- Difficult to test
- Difficult to reuse
2. **Module Boundaries are Blurred**
- Onboarding imports from setup-wizard
- Difficult to modify either independently
- Violates separation of concerns
3. **State Management is Confusing**
- UI state mixed with data state
- Bidirectional data flow
- Multiple sources of truth
4. **No Clear Contracts Between Steps**
- Data types change between steps
- Implicit transformations
- Runtime type errors possible
---
## How to Use This Document
1. **For Planning**: Use Phase 1-5 to plan sprints
2. **For Understanding**: Read ARCHITECTURE_ANALYSIS.md for detailed breakdown
3. **For Implementation**: Follow the refactoring approach in each section
4. **For Maintenance**: Reference this after refactoring to keep architecture clean
---
## Success Criteria
After refactoring, the codebase should have:
- [ ] UploadSalesDataStep is <300 lines
- [ ] No imports between onboarding and setup-wizard domains
- [ ] Clear separation of UI state and data state
- [ ] All steps follow same interface contract
- [ ] Data transformations are explicit and testable
- [ ] Unit test coverage >80% for all step components
- [ ] All 8 architectural problems resolved
- [ ] Deprecated files deleted (OnboardingWizard.tsx)
---
## Estimated Effort
| Phase | Effort | Impact | Timeline |
|-------|--------|--------|----------|
| Phase 1 (Quick Wins) | 2-3 hours | LOW | Week 1 |
| Phase 2 (State Management) | 3-4 hours | MEDIUM | Week 2 |
| Phase 3 (Split Component) | 6-8 hours | CRITICAL | Sprint 2 |
| Phase 4 (Decouple Domains) | 4-6 hours | HIGH | Sprint 3 |
| Phase 5 (Data Contracts) | 4-6 hours | MEDIUM | Sprint 4 |
| **TOTAL** | **19-27 hours** | **HIGH** | **4-5 Weeks** |
---
## Contact & Questions
For detailed architecture information, see: `ARCHITECTURE_ANALYSIS.md`
Key sections:
- Section 2: Detailed architectural issues with code examples
- Section 3: Step dependencies and data flow diagrams
- Section 6: Problem statements for each issue
- Section 8: Detailed recommendations for each refactoring task

View File

@@ -5,23 +5,41 @@
import { apiClient } from '../client'; import { apiClient } from '../client';
import { UserProgress, UpdateStepRequest } from '../types/onboarding'; import { UserProgress, UpdateStepRequest } from '../types/onboarding';
// Backend onboarding steps (full list from backend) // Backend onboarding steps (full list from backend - UPDATED to match refactored flow)
export const BACKEND_ONBOARDING_STEPS = [ export const BACKEND_ONBOARDING_STEPS = [
'user_registered', // Auto-completed: User account created 'user_registered', // Phase 0: User account created (auto-completed)
'setup', // Step 1: Basic bakery setup and tenant creation 'bakery-type-selection', // Phase 1: Choose bakery type
'smart-inventory-setup', // Step 2: Sales data upload and inventory configuration 'setup', // Phase 2: Basic bakery setup and tenant creation
'suppliers', // Step 3: Suppliers configuration (optional) 'upload-sales-data', // Phase 2a: File upload, validation, AI classification
'ml-training', // Step 4: AI model training 'inventory-review', // Phase 2a: Review AI-detected products with type selection
'completion' // Step 5: Onboarding completed 'initial-stock-entry', // Phase 2a: Capture initial stock levels
'product-categorization', // Phase 2b: Advanced categorization (optional)
'suppliers-setup', // Phase 2c: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training
'setup-review', // Phase 4: Review all configuration
'completion' // Phase 4: Onboarding completed
]; ];
// Frontend step order for navigation (excludes user_registered as it's auto-completed) // Frontend step order for navigation (excludes user_registered as it's auto-completed)
export const FRONTEND_STEP_ORDER = [ export const FRONTEND_STEP_ORDER = [
'setup', // Step 1: Basic bakery setup and tenant creation 'bakery-type-selection', // Phase 1: Choose bakery type
'smart-inventory-setup', // Step 2: Sales data upload and inventory configuration 'setup', // Phase 2: Basic bakery setup and tenant creation
'suppliers', // Step 3: Suppliers configuration (optional) 'upload-sales-data', // Phase 2a: File upload and AI classification
'ml-training', // Step 4: AI model training 'inventory-review', // Phase 2a: Review AI-detected products
'completion' // Step 5: Onboarding completed 'initial-stock-entry', // Phase 2a: Initial stock levels
'product-categorization', // Phase 2b: Advanced categorization (optional)
'suppliers-setup', // Phase 2c: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training
'setup-review', // Phase 4: Review configuration
'completion' // Phase 4: Onboarding completed
]; ];
export class OnboardingService { export class OnboardingService {

View File

@@ -95,10 +95,11 @@ export interface IngredientCreate {
// Note: average_cost is calculated automatically from purchases (not accepted on create) // Note: average_cost is calculated automatically from purchases (not accepted on create)
standard_cost?: number | null; standard_cost?: number | null;
// Stock management // Stock management - all optional for onboarding
low_stock_threshold?: number; // Default: 10.0 // These can be configured later based on actual usage patterns
reorder_point?: number; // Default: 20.0 low_stock_threshold?: number | null;
reorder_quantity?: number; // Default: 50.0 reorder_point?: number | null;
reorder_quantity?: number | null;
max_stock_level?: number | null; max_stock_level?: number | null;
// Shelf life (default value only - actual per batch) // Shelf life (default value only - actual per batch)
@@ -158,9 +159,9 @@ export interface IngredientResponse {
average_cost: number | null; average_cost: number | null;
last_purchase_price: number | null; last_purchase_price: number | null;
standard_cost: number | null; standard_cost: number | null;
low_stock_threshold: number; low_stock_threshold: number | null; // Now optional
reorder_point: number; reorder_point: number | null; // Now optional
reorder_quantity: number; reorder_quantity: number | null; // Now optional
max_stock_level: number | null; max_stock_level: number | null;
shelf_life_days: number | null; // Default value only shelf_life_days: number | null; // Default value only
is_active: boolean; is_active: boolean;

View File

@@ -1,567 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/Button';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { useAuth } from '../../../contexts/AuthContext';
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
import { useTenantActions } from '../../../stores/tenant.store';
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
import {
RegisterTenantStep,
UploadSalesDataStep,
MLTrainingStep,
CompletionStep
} from './steps';
import { Building2 } from 'lucide-react';
interface StepConfig {
id: string;
title: string;
description: string;
component: React.ComponentType<StepProps>;
}
interface StepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
export const OnboardingWizard: React.FC = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
// Steps must match backend ONBOARDING_STEPS exactly
// Note: "user_registered" is auto-completed and not shown in UI
const STEPS: StepConfig[] = [
{
id: 'setup',
title: t('onboarding:wizard.steps.setup.title', 'Registrar Panadería'),
description: t('onboarding:wizard.steps.setup.description', 'Configura la información básica de tu panadería'),
component: RegisterTenantStep,
},
{
id: 'smart-inventory-setup',
title: t('onboarding:wizard.steps.smart_inventory_setup.title', 'Configurar Inventario'),
description: t('onboarding:wizard.steps.smart_inventory_setup.description', 'Sube datos de ventas y configura tu inventario inicial'),
component: UploadSalesDataStep,
},
{
id: 'ml-training',
title: t('onboarding:wizard.steps.ml_training.title', 'Entrenamiento IA'),
description: t('onboarding:wizard.steps.ml_training.description', 'Entrena tu modelo de inteligencia artificial personalizado'),
component: MLTrainingStep,
},
{
id: 'completion',
title: t('onboarding:wizard.steps.completion.title', 'Configuración Completa'),
description: t('onboarding:wizard.steps.completion.description', '¡Bienvenido a tu sistema de gestión inteligente!'),
component: CompletionStep,
},
];
// Check if this is a fresh onboarding (new tenant creation)
const isNewTenant = searchParams.get('new') === 'true';
// Initialize state based on whether this is a new tenant or not
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isInitialized, setIsInitialized] = useState(isNewTenant); // If new tenant, consider initialized immediately
// Debug log for new tenant creation
useEffect(() => {
if (isNewTenant) {
console.log('🆕 New tenant creation detected - UI will reset to step 0');
console.log('📊 Current step index:', currentStepIndex);
console.log('🎯 Is initialized:', isInitialized);
}
}, [isNewTenant, currentStepIndex, isInitialized]);
// Initialize tenant data for authenticated users
useTenantInitializer();
// Get user progress from backend
const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant } = useTenantActions();
// Auto-complete user_registered step if needed (runs first)
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
useEffect(() => {
if (userProgress && user?.id && !autoCompletionAttempted && !markStepCompleted.isPending) {
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
if (!userRegisteredStep?.completed) {
console.log('🔄 Auto-completing user_registered step for new user...');
setAutoCompletionAttempted(true);
// Merge with any existing data (e.g., subscription_plan from registration)
const existingData = userRegisteredStep?.data || {};
markStepCompleted.mutate({
userId: user.id,
stepName: 'user_registered',
data: {
...existingData, // Preserve existing data like subscription_plan
auto_completed: true,
completed_at: new Date().toISOString(),
source: 'onboarding_wizard_auto_completion'
}
}, {
onSuccess: () => {
console.log('✅ user_registered step auto-completed successfully');
// The query will automatically refetch and update userProgress
},
onError: (error) => {
console.error('❌ Failed to auto-complete user_registered step:', error);
// Reset flag on error to allow retry
setAutoCompletionAttempted(false);
}
});
}
}
}, [userProgress, user?.id, autoCompletionAttempted, markStepCompleted.isPending]); // Removed markStepCompleted from deps
// Initialize step index based on backend progress with validation
useEffect(() => {
// Skip backend progress loading for new tenant creation
if (isNewTenant) {
return; // Already initialized to step 0
}
if (userProgress && !isInitialized) {
console.log('🔄 Initializing onboarding progress:', userProgress);
// Check if user_registered step is completed
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
if (!userRegisteredStep?.completed) {
console.log('⏳ Waiting for user_registered step to be auto-completed...');
return; // Wait for auto-completion to finish
}
let stepIndex = 0; // Default to first step
// If this is a new tenant creation, always start from the beginning
if (isNewTenant) {
console.log('🆕 New tenant creation - starting from first step');
stepIndex = 0;
} else {
// Find the current step index based on backend progress
const currentStepFromBackend = userProgress.current_step;
stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend);
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
// If current step is not found (e.g., suppliers step), find the next incomplete step
if (stepIndex === -1) {
console.log('🔍 Current step not found in UI steps, finding first incomplete step...');
// Find the first incomplete step that user can access
for (let i = 0; i < STEPS.length; i++) {
const step = STEPS[i];
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
if (!stepProgress?.completed) {
stepIndex = i;
console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`);
break;
}
}
// If all visible steps are completed, go to last step
if (stepIndex === -1) {
stepIndex = STEPS.length - 1;
console.log('✅ All steps completed, going to last step');
}
}
// Ensure user can't skip ahead - find the first incomplete step
const firstIncompleteStepIndex = STEPS.findIndex(step => {
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
return !stepProgress?.completed;
});
if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) {
console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`);
stepIndex = firstIncompleteStepIndex;
}
}
console.log(`🎯 Final step index: ${stepIndex} ("${STEPS[stepIndex]?.id}")`);
if (stepIndex !== currentStepIndex) {
setCurrentStepIndex(stepIndex);
}
setIsInitialized(true);
}
}, [userProgress, isInitialized, currentStepIndex, isNewTenant]);
const currentStep = STEPS[currentStepIndex];
const handleStepComplete = async (data?: any) => {
if (!user?.id) {
console.error('User ID not available');
return;
}
// Prevent concurrent mutations
if (markStepCompleted.isPending) {
console.warn(`⚠️ Step completion already in progress for "${currentStep.id}", skipping duplicate call`);
return;
}
console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data);
try {
// Special handling for setup step - set the created tenant in tenant store
if (currentStep.id === 'setup' && data?.tenant) {
setCurrentTenant(data.tenant);
}
// Mark step as completed in backend
console.log(`📤 Sending API request to complete step: "${currentStep.id}"`);
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: currentStep.id,
data
});
console.log(`✅ Successfully completed step: "${currentStep.id}"`);
// Special handling for smart-inventory-setup: auto-complete suppliers step
if (currentStep.id === 'smart-inventory-setup' && data?.shouldAutoCompleteSuppliers) {
try {
console.log('🔄 Auto-completing suppliers step to enable ML training...');
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: 'suppliers',
data: {
auto_completed: true,
completed_at: new Date().toISOString(),
source: 'inventory_creation_auto_completion',
message: 'Suppliers step auto-completed to proceed with ML training'
}
});
console.log('✅ Suppliers step auto-completed successfully');
} catch (supplierError) {
console.warn('⚠️ Could not auto-complete suppliers step:', supplierError);
// Don't fail the entire flow if suppliers step completion fails
}
}
if (currentStep.id === 'completion') {
// Navigate to dashboard after completion
if (isNewTenant) {
// For new tenant creation, navigate to dashboard and remove the new param
navigate('/app/dashboard');
} else {
navigate('/app');
}
} else {
// Auto-advance to next step after successful completion
if (currentStepIndex < STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
}
}
} catch (error: any) {
console.error(`❌ Error completing step "${currentStep.id}":`, error);
// Extract detailed error information
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
const statusCode = error?.response?.status;
console.error(`📊 Error details: Status ${statusCode}, Message: ${errorMessage}`);
// Handle different types of errors
if (statusCode === 207) {
// Multi-Status: Step updated but summary failed
console.warn(`⚠️ Partial success for step "${currentStep.id}": ${errorMessage}`);
// Continue with step advancement since the actual step was completed
if (currentStep.id === 'completion') {
// Navigate to dashboard after completion
if (isNewTenant) {
navigate('/app/dashboard');
} else {
navigate('/app');
}
} else {
// Auto-advance to next step after successful completion
if (currentStepIndex < STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
}
}
// Show a warning but don't block progress
console.warn(`Step "${currentStep.title}" completed with warnings: ${errorMessage}`);
return; // Don't show error alert
}
// Check if it's a dependency error
if (errorMessage.includes('dependencies not met')) {
console.error('🚫 Dependencies not met for step:', currentStep.id);
// Check what dependencies are missing
if (userProgress) {
console.log('📋 Current progress:', userProgress);
console.log('📋 Completed steps:', userProgress.steps.filter(s => s.completed).map(s => s.step_name));
}
}
// Don't advance automatically on real errors - user should see the issue
alert(`${t('onboarding:errors.step_failed', 'Error al completar paso')} "${currentStep.title}": ${errorMessage}`);
}
};
// Show loading state while initializing progress (skip for new tenant)
if (!isNewTenant && (isLoadingProgress || !isInitialized)) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="flex items-center justify-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)] text-sm sm:text-base">{t('common:loading', 'Cargando tu progreso...')}</p>
</div>
</CardBody>
</Card>
</div>
);
}
// Show error state if progress fails to load (skip for new tenant)
if (!isNewTenant && progressError) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="text-center space-y-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 mx-auto bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-[var(--color-error)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold text-[var(--text-primary)] mb-2">
{t('onboarding:errors.network_error', 'Error al cargar progreso')}
</h2>
<p className="text-sm sm:text-base text-[var(--text-secondary)] mb-4 px-2">
{t('onboarding:errors.try_again', 'No pudimos cargar tu progreso de configuración. Puedes continuar desde el inicio.')}
</p>
<Button
onClick={() => setIsInitialized(true)}
variant="primary"
size="lg"
>
{t('onboarding:wizard.navigation.next', 'Continuar')}
</Button>
</div>
</div>
</CardBody>
</Card>
</div>
);
}
const StepComponent = currentStep.component;
// Calculate progress percentage - reset for new tenant creation
const progressPercentage = isNewTenant
? ((currentStepIndex + 1) / STEPS.length) * 100 // For new tenant, base progress only on current step
: userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100;
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6">
{/* New Tenant Info Banner */}
{isNewTenant && (
<Card className="bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border-[var(--color-primary)]/20">
<CardBody className="py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<Building2 className="w-4 h-4 text-[var(--color-primary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">
{t('onboarding:wizard.title', 'Creando Nueva Organización')}
</p>
<p className="text-xs text-[var(--text-secondary)]">
{t('onboarding:wizard.subtitle', 'Configurarás una nueva panadería desde cero. Este proceso es independiente de tus organizaciones existentes.')}
</p>
</div>
</div>
</CardBody>
</Card>
)}
{/* Enhanced Progress Header */}
<Card shadow="sm" padding="lg">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0">
<div className="text-center sm:text-left">
<h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]">
{isNewTenant ? t('onboarding:wizard.title', 'Crear Nueva Organización') : t('onboarding:wizard.title', 'Bienvenido a Bakery IA')}
</h1>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
{isNewTenant
? t('onboarding:wizard.subtitle', 'Configura tu nueva panadería desde cero')
: t('onboarding:wizard.subtitle', 'Configura tu sistema de gestión inteligente paso a paso')
}
</p>
</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}}', { current: currentStepIndex + 1, total: STEPS.length })}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{Math.round(progressPercentage)}% {t('onboarding:wizard.progress.completed', 'completado')}
{isNewTenant && <span className="text-[var(--color-primary)] ml-1">(nuevo)</span>}
</div>
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2 sm:h-3 mb-4">
<div
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary)]/80 h-2 sm:h-3 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progressPercentage}%` }}
/>
</div>
{/* Mobile Step Indicators - Horizontal scroll on small screens */}
<div className="sm:hidden">
<div className="flex space-x-4 overflow-x-auto pb-2 px-1">
{STEPS.map((step, index) => {
// For new tenant creation, only show completed if index is less than current step
const isCompleted = isNewTenant
? index < currentStepIndex
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<div
key={step.id}
className={`flex-shrink-0 text-center min-w-[80px] ${
isCompleted
? 'text-[var(--color-success)]'
: isCurrent
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
}`}
>
<div className="flex items-center justify-center mb-1">
{isCompleted ? (
<div className="w-8 h-8 bg-[var(--color-success)] rounded-full flex items-center justify-center shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : isCurrent ? (
<div className="w-8 h-8 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold shadow-sm ring-2 ring-[var(--color-primary)]/20">
{index + 1}
</div>
) : (
<div className="w-8 h-8 bg-[var(--bg-tertiary)] rounded-full flex items-center justify-center text-[var(--text-tertiary)] text-sm">
{index + 1}
</div>
)}
</div>
<div className="text-xs font-medium leading-tight">
{step.title}
</div>
</div>
);
})}
</div>
</div>
{/* Desktop Step Indicators */}
<div className="hidden sm:flex sm:justify-between">
{STEPS.map((step, index) => {
// For new tenant creation, only show completed if index is less than current step
const isCompleted = isNewTenant
? index < currentStepIndex
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<div
key={step.id}
className={`flex-1 text-center px-2 ${
isCompleted
? 'text-[var(--color-success)]'
: isCurrent
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
}`}
>
<div className="flex items-center justify-center mb-2">
{isCompleted ? (
<div className="w-7 h-7 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : isCurrent ? (
<div className="w-7 h-7 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold">
{index + 1}
</div>
) : (
<div className="w-7 h-7 bg-[var(--bg-tertiary)] rounded-full flex items-center justify-center text-[var(--text-tertiary)] text-sm">
{index + 1}
</div>
)}
</div>
<div className="text-xs sm:text-sm font-medium mb-1">
{step.title}
</div>
<div className="text-xs opacity-75">
{step.description}
</div>
</div>
);
})}
</div>
</Card>
{/* Step Content */}
<Card shadow="lg" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<div className="w-5 h-5 sm:w-6 sm:h-6 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-xs font-bold">
{currentStepIndex + 1}
</div>
</div>
<div className="flex-1">
<h2 className="text-lg sm:text-xl font-semibold text-[var(--text-primary)]">
{currentStep.title}
</h2>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm">
{currentStep.description}
</p>
</div>
</div>
</CardHeader>
<CardBody padding="lg">
<StepComponent
onNext={() => {}} // No-op - steps must use onComplete instead
onPrevious={() => {}} // No-op - users cannot go back once they've moved forward
onComplete={handleStepComplete}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === STEPS.length - 1}
/>
</CardBody>
</Card>
</div>
);
};

View File

@@ -11,7 +11,8 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
import { import {
BakeryTypeSelectionStep, BakeryTypeSelectionStep,
RegisterTenantStep, RegisterTenantStep,
UploadSalesDataStep, FileUploadStep,
InventoryReviewStep,
ProductCategorizationStep, ProductCategorizationStep,
InitialStockEntryStep, InitialStockEntryStep,
ProductionProcessesStep, ProductionProcessesStep,
@@ -73,20 +74,20 @@ const OnboardingWizardContent: React.FC = () => {
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null, condition: (ctx) => ctx.state.bakeryType !== null,
}, },
// Phase 2a: AI-Assisted Path (ONLY PATH NOW) // Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
{ {
id: 'smart-inventory-setup', id: 'upload-sales-data',
title: t('onboarding:steps.smart_inventory.title', 'Subir Datos de Ventas'), title: t('onboarding:steps.upload_sales.title', 'Subir Datos de Ventas'),
description: t('onboarding:steps.smart_inventory.description', 'Configuración con IA'), description: t('onboarding:steps.upload_sales.description', 'Cargar archivo con historial de ventas'),
component: UploadSalesDataStep, component: FileUploadStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.tenantId !== null, condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
}, },
{ {
id: 'product-categorization', id: 'inventory-review',
title: t('onboarding:steps.categorization.title', 'Categorizar Productos'), title: t('onboarding:steps.inventory_review.title', 'Revisar Inventario'),
description: t('onboarding:steps.categorization.description', 'Clasifica ingredientes vs productos'), description: t('onboarding:steps.inventory_review.description', 'Confirmar productos detectados'),
component: ProductCategorizationStep, component: InventoryReviewStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.aiAnalysisComplete, condition: (ctx) => ctx.state.aiAnalysisComplete,
}, },
@@ -96,7 +97,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.stock.description', 'Cantidades iniciales'), description: t('onboarding:steps.stock.description', 'Cantidades iniciales'),
component: InitialStockEntryStep, component: InitialStockEntryStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.categorizationCompleted, condition: (ctx) => ctx.state.inventoryReviewCompleted,
}, },
{ {
id: 'suppliers-setup', id: 'suppliers-setup',
@@ -130,7 +131,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.quality.description', 'Estándares de calidad'), description: t('onboarding:steps.quality.description', 'Estándares de calidad'),
component: QualitySetupStep, component: QualitySetupStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.tenantId !== null, condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
}, },
{ {
id: 'team-setup', id: 'team-setup',
@@ -138,7 +139,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.team.description', 'Miembros del equipo'), description: t('onboarding:steps.team.description', 'Miembros del equipo'),
component: TeamSetupStep, component: TeamSetupStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.tenantId !== null, condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
}, },
// Phase 4: ML & Finalization // Phase 4: ML & Finalization
{ {
@@ -154,7 +155,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.review.description', 'Confirma tu configuración'), description: t('onboarding:steps.review.description', 'Confirma tu configuración'),
component: ReviewSetupStep, component: ReviewSetupStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.tenantId !== null, condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
}, },
{ {
id: 'completion', id: 'completion',
@@ -182,7 +183,7 @@ const OnboardingWizardContent: React.FC = () => {
}); });
return visibleSteps; return visibleSteps;
}, [wizardContext.state, wizardContext.tenantId]); }, [wizardContext.state]);
const isNewTenant = searchParams.get('new') === 'true'; const isNewTenant = searchParams.get('new') === 'true';
const [currentStepIndex, setCurrentStepIndex] = useState(0); const [currentStepIndex, setCurrentStepIndex] = useState(0);
@@ -316,10 +317,15 @@ const OnboardingWizardContent: React.FC = () => {
if (currentStep.id === 'data-source-choice' && data?.dataSource) { if (currentStep.id === 'data-source-choice' && data?.dataSource) {
wizardContext.updateDataSource(data.dataSource as DataSource); wizardContext.updateDataSource(data.dataSource as DataSource);
} }
if (currentStep.id === 'smart-inventory-setup' && data?.aiSuggestions) { // REFACTORED: Handle new split steps for AI-assisted inventory
if (currentStep.id === 'upload-sales-data' && data?.aiSuggestions) {
wizardContext.updateAISuggestions(data.aiSuggestions); wizardContext.updateAISuggestions(data.aiSuggestions);
wizardContext.updateUploadedFile(data.uploadedFile, data.validationResult);
wizardContext.setAIAnalysisComplete(true); wizardContext.setAIAnalysisComplete(true);
} }
if (currentStep.id === 'inventory-review') {
wizardContext.markStepComplete('inventoryReviewCompleted');
}
if (currentStep.id === 'product-categorization' && data?.categorizedProducts) { if (currentStep.id === 'product-categorization' && data?.categorizedProducts) {
wizardContext.updateCategorizedProducts(data.categorizedProducts); wizardContext.updateCategorizedProducts(data.categorizedProducts);
wizardContext.markStepComplete('categorizationCompleted'); wizardContext.markStepComplete('categorizationCompleted');
@@ -345,8 +351,8 @@ const OnboardingWizardContent: React.FC = () => {
console.log(`✅ Successfully completed step: "${currentStep.id}"`); console.log(`✅ Successfully completed step: "${currentStep.id}"`);
// Special handling for smart-inventory-setup // Special handling for inventory-review - auto-complete suppliers if requested
if (currentStep.id === 'smart-inventory-setup' && data?.shouldAutoCompleteSuppliers) { if (currentStep.id === 'inventory-review' && data?.shouldAutoCompleteSuppliers) {
try { try {
console.log('🔄 Auto-completing suppliers-setup step...'); console.log('🔄 Auto-completing suppliers-setup step...');
await markStepCompleted.mutateAsync({ await markStepCompleted.mutateAsync({
@@ -452,12 +458,12 @@ const OnboardingWizardContent: React.FC = () => {
: userProgress?.completion_percentage || ((currentStepIndex + 1) / VISIBLE_STEPS.length) * 100; : userProgress?.completion_percentage || ((currentStepIndex + 1) / VISIBLE_STEPS.length) * 100;
return ( return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6"> <div className="max-w-4xl mx-auto px-2 sm:px-4 md:px-6 space-y-3 sm:space-y-4 md:space-y-6 pb-4 md:pb-6">
{/* Progress Header */} {/* Progress Header */}
<Card shadow="sm" padding="lg"> <Card shadow="sm" padding="md">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-3 sm:mb-4 space-y-2 sm:space-y-0">
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]"> <h1 className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--text-primary)]">
{isNewTenant ? t('onboarding:wizard.title_new', 'Nueva Panadería') : t('onboarding:wizard.title', 'Configuración Inicial')} {isNewTenant ? t('onboarding:wizard.title_new', 'Nueva Panadería') : t('onboarding:wizard.title', 'Configuración Inicial')}
</h1> </h1>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1"> <p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
@@ -488,9 +494,9 @@ const OnboardingWizardContent: React.FC = () => {
{/* Step Content */} {/* Step Content */}
<Card shadow="lg" padding="none"> <Card shadow="lg" padding="none">
<CardHeader padding="lg" divider> <CardHeader padding="md" divider>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-2 sm:space-x-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center flex-shrink-0">
<div className="w-5 h-5 sm:w-6 sm:h-6 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-xs font-bold"> <div className="w-5 h-5 sm:w-6 sm:h-6 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-xs font-bold">
{currentStepIndex + 1} {currentStepIndex + 1}
</div> </div>
@@ -506,7 +512,7 @@ const OnboardingWizardContent: React.FC = () => {
</div> </div>
</CardHeader> </CardHeader>
<CardBody padding="lg"> <CardBody padding="md">
<StepComponent <StepComponent
onNext={() => {}} onNext={() => {}}
onPrevious={() => {}} onPrevious={() => {}}
@@ -515,6 +521,18 @@ const OnboardingWizardContent: React.FC = () => {
isFirstStep={currentStepIndex === 0} isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === VISIBLE_STEPS.length - 1} isLastStep={currentStepIndex === VISIBLE_STEPS.length - 1}
canContinue={canContinue} canContinue={canContinue}
initialData={
// Pass AI data and file to InventoryReviewStep
currentStep.id === 'inventory-review'
? {
uploadedFile: wizardContext.state.uploadedFile,
validationResult: wizardContext.state.uploadedFileValidation,
aiSuggestions: wizardContext.state.aiSuggestions,
uploadedFileName: wizardContext.state.uploadedFileName || '',
uploadedFileSize: wizardContext.state.uploadedFileSize || 0,
}
: undefined
}
/> />
</CardBody> </CardBody>
</Card> </Card>

View File

@@ -1,8 +1,11 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type { ProductSuggestionResponse } from '../../../api/types/inventory';
import type { ImportValidationResponse } from '../../../api/types/dataImport';
export type BakeryType = 'production' | 'retail' | 'mixed' | null; export type BakeryType = 'production' | 'retail' | 'mixed' | null;
export type DataSource = 'ai-assisted' | 'manual' | null; export type DataSource = 'ai-assisted' | 'manual' | null;
// Legacy AISuggestion type - kept for backward compatibility
export interface AISuggestion { export interface AISuggestion {
id: string; id: string;
name: string; name: string;
@@ -19,15 +22,18 @@ export interface WizardState {
dataSource: DataSource; dataSource: DataSource;
// AI-Assisted Path Data // AI-Assisted Path Data
uploadedFile?: File; // NEW: The actual file object needed for sales import API
uploadedFileName?: string; uploadedFileName?: string;
uploadedFileSize?: number; uploadedFileSize?: number;
aiSuggestions: AISuggestion[]; uploadedFileValidation?: ImportValidationResponse; // NEW: Validation result
aiSuggestions: ProductSuggestionResponse[]; // UPDATED: Use full ProductSuggestionResponse type
aiAnalysisComplete: boolean; aiAnalysisComplete: boolean;
categorizedProducts?: any[]; // Products with type classification categorizedProducts?: any[]; // Products with type classification
productsWithStock?: any[]; // Products with initial stock levels productsWithStock?: any[]; // Products with initial stock levels
// Setup Progress // Setup Progress
categorizationCompleted: boolean; categorizationCompleted: boolean;
inventoryReviewCompleted: boolean; // NEW: Tracks completion of InventoryReviewStep
stockEntryCompleted: boolean; stockEntryCompleted: boolean;
suppliersCompleted: boolean; suppliersCompleted: boolean;
inventoryCompleted: boolean; inventoryCompleted: boolean;
@@ -49,7 +55,8 @@ export interface WizardContextValue {
state: WizardState; state: WizardState;
updateBakeryType: (type: BakeryType) => void; updateBakeryType: (type: BakeryType) => void;
updateDataSource: (source: DataSource) => void; updateDataSource: (source: DataSource) => void;
updateAISuggestions: (suggestions: AISuggestion[]) => void; updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
setAIAnalysisComplete: (complete: boolean) => void; setAIAnalysisComplete: (complete: boolean) => void;
updateCategorizedProducts: (products: any[]) => void; updateCategorizedProducts: (products: any[]) => void;
updateProductsWithStock: (products: any[]) => void; updateProductsWithStock: (products: any[]) => void;
@@ -67,6 +74,7 @@ const initialState: WizardState = {
categorizedProducts: undefined, categorizedProducts: undefined,
productsWithStock: undefined, productsWithStock: undefined,
categorizationCompleted: false, categorizationCompleted: false,
inventoryReviewCompleted: false, // NEW: Initially false
stockEntryCompleted: false, stockEntryCompleted: false,
suppliersCompleted: false, suppliersCompleted: false,
inventoryCompleted: false, inventoryCompleted: false,
@@ -118,10 +126,20 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
setState(prev => ({ ...prev, dataSource: source })); setState(prev => ({ ...prev, dataSource: source }));
}; };
const updateAISuggestions = (suggestions: AISuggestion[]) => { const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
setState(prev => ({ ...prev, aiSuggestions: suggestions })); setState(prev => ({ ...prev, aiSuggestions: suggestions }));
}; };
const updateUploadedFile = (file: File, validation: ImportValidationResponse) => {
setState(prev => ({
...prev,
uploadedFile: file,
uploadedFileName: file.name,
uploadedFileSize: file.size,
uploadedFileValidation: validation,
}));
};
const setAIAnalysisComplete = (complete: boolean) => { const setAIAnalysisComplete = (complete: boolean) => {
setState(prev => ({ ...prev, aiAnalysisComplete: complete })); setState(prev => ({ ...prev, aiAnalysisComplete: complete }));
}; };
@@ -227,6 +245,7 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
updateBakeryType, updateBakeryType,
updateDataSource, updateDataSource,
updateAISuggestions, updateAISuggestions,
updateUploadedFile,
setAIAnalysisComplete, setAIAnalysisComplete,
updateCategorizedProducts, updateCategorizedProducts,
updateProductsWithStock, updateProductsWithStock,

View File

@@ -1,2 +1,3 @@
export { OnboardingWizard } from './OnboardingWizard'; // OnboardingWizard.tsx has been deleted - it was deprecated and unused
// All onboarding now uses UnifiedOnboardingWizard
export { UnifiedOnboardingWizard } from './UnifiedOnboardingWizard'; export { UnifiedOnboardingWizard } from './UnifiedOnboardingWizard';

View File

@@ -118,13 +118,13 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
}; };
return ( return (
<div className="max-w-6xl mx-auto p-6 space-y-8"> <div className="max-w-6xl mx-auto p-4 md:p-6 space-y-6 md:space-y-8">
{/* Header */} {/* Header */}
<div className="text-center space-y-3"> <div className="text-center space-y-3 md:space-y-4">
<h1 className="text-3xl font-bold text-text-primary"> <h1 className="text-2xl md:text-3xl font-bold text-[var(--text-primary)] px-2">
{t('onboarding:bakery_type.title', '¿Qué tipo de panadería tienes?')} {t('onboarding:bakery_type.title', '¿Qué tipo de panadería tienes?')}
</h1> </h1>
<p className="text-lg text-text-secondary max-w-2xl mx-auto"> <p className="text-base md:text-lg text-[var(--text-secondary)] max-w-2xl mx-auto px-4">
{t( {t(
'onboarding:bakery_type.subtitle', 'onboarding:bakery_type.subtitle',
'Esto nos ayudará a personalizar la experiencia y mostrarte solo las funciones que necesitas' 'Esto nos ayudará a personalizar la experiencia y mostrarte solo las funciones que necesitas'
@@ -139,54 +139,60 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
const isHovered = hoveredType === type.id; const isHovered = hoveredType === type.id;
return ( return (
<Card <button
key={type.id} key={type.id}
className={` type="button"
relative cursor-pointer transition-all duration-300 overflow-hidden
${isSelected ? 'ring-4 ring-primary-500 shadow-2xl scale-105' : 'hover:shadow-xl hover:scale-102'}
${isHovered && !isSelected ? 'shadow-lg' : ''}
`}
onClick={() => handleSelectType(type.id)} onClick={() => handleSelectType(type.id)}
onMouseEnter={() => setHoveredType(type.id)} onMouseEnter={() => setHoveredType(type.id)}
onMouseLeave={() => setHoveredType(null)} onMouseLeave={() => setHoveredType(null)}
className={`
relative cursor-pointer transition-all duration-300 overflow-hidden
border-2 rounded-lg text-left w-full
bg-[var(--bg-secondary)]
${isSelected
? 'border-[var(--color-primary)] shadow-lg ring-2 ring-[var(--color-primary)]/50 scale-[1.02]'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:shadow-md'
}
${isHovered && !isSelected ? 'shadow-sm' : ''}
`}
> >
{/* Selection Indicator */} {/* Selection Indicator */}
{isSelected && ( {isSelected && (
<div className="absolute top-4 right-4 z-10"> <div className="absolute top-4 right-4 z-10">
<div className="w-8 h-8 bg-primary-500 rounded-full flex items-center justify-center shadow-lg animate-scale-in"> <div className="w-8 h-8 bg-[var(--color-primary)] rounded-full flex items-center justify-center shadow-lg">
<Check className="w-5 h-5 text-white" strokeWidth={3} /> <Check className="w-5 h-5 text-white" strokeWidth={3} />
</div> </div>
</div> </div>
)} )}
{/* Gradient Background */} {/* Accent Background */}
<div className={`absolute inset-0 ${type.gradient} opacity-40 transition-opacity ${isSelected ? 'opacity-60' : ''}`} /> <div className={`absolute inset-0 bg-[var(--color-primary)]/5 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
{/* Content */} {/* Content */}
<div className="relative p-6 space-y-4"> <div className="relative p-4 md:p-6 space-y-3 md:space-y-4">
{/* Icon & Title */} {/* Icon & Title */}
<div className="space-y-3"> <div className="space-y-2 md:space-y-3">
<div className="text-5xl">{type.icon}</div> <div className="text-4xl md:text-5xl">{type.icon}</div>
<h3 className="text-xl font-bold text-text-primary"> <h3 className="text-lg md:text-xl font-bold text-[var(--text-primary)]">
{type.name} {type.name}
</h3> </h3>
<p className="text-sm text-text-secondary leading-relaxed"> <p className="text-sm text-[var(--text-secondary)] leading-relaxed">
{type.description} {type.description}
</p> </p>
</div> </div>
{/* Features */} {/* Features */}
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wide"> <h4 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
{t('onboarding:bakery_type.features_label', 'Características')} {t('onboarding:bakery_type.features_label', 'Características')}
</h4> </h4>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{type.features.map((feature, index) => ( {type.features.map((feature, index) => (
<li <li
key={index} key={index}
className="text-sm text-text-primary flex items-start gap-2" className="text-sm text-[var(--text-primary)] flex items-start gap-2"
> >
<span className="text-primary-500 mt-0.5 flex-shrink-0"></span> <span className="text-[var(--color-primary)] mt-0.5 flex-shrink-0"></span>
<span>{feature}</span> <span>{feature}</span>
</li> </li>
))} ))}
@@ -194,15 +200,15 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
</div> </div>
{/* Examples */} {/* Examples */}
<div className="space-y-2 pt-2 border-t border-border-primary"> <div className="space-y-2 pt-2 border-t border-[var(--border-color)]">
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wide"> <h4 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
{t('onboarding:bakery_type.examples_label', 'Ejemplos')} {t('onboarding:bakery_type.examples_label', 'Ejemplos')}
</h4> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{type.examples.map((example, index) => ( {type.examples.map((example, index) => (
<span <span
key={index} key={index}
className="text-xs px-2 py-1 bg-bg-secondary rounded-full text-text-secondary" className="text-xs px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-full text-[var(--text-secondary)]"
> >
{example} {example}
</span> </span>
@@ -210,45 +216,23 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
</div> </div>
</div> </div>
</div> </div>
</Card> </button>
); );
})} })}
</div> </div>
{/* Help Text */}
<div className="text-center space-y-4">
<p className="text-sm text-text-secondary">
{t(
'onboarding:bakery_type.help_text',
'💡 No te preocupes, siempre puedes cambiar esto más tarde en la configuración'
)}
</p>
{/* Continue Button */}
<div className="flex justify-center pt-4">
<Button
onClick={handleContinue}
disabled={!selectedType}
size="lg"
className="min-w-[200px]"
>
{t('onboarding:bakery_type.continue_button', 'Continuar')}
</Button>
</div>
</div>
{/* Additional Info */} {/* Additional Info */}
{selectedType && ( {selectedType && (
<div className="mt-8 p-6 bg-primary-50 border border-primary-200 rounded-lg animate-fade-in"> <div className="bg-[var(--color-primary)]/10 border border-[var(--color-primary)]/20 rounded-lg p-4 md:p-6">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="text-2xl flex-shrink-0"> <div className="text-2xl md:text-3xl flex-shrink-0">
{bakeryTypes.find(t => t.id === selectedType)?.icon} {bakeryTypes.find(t => t.id === selectedType)?.icon}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-semibold text-text-primary"> <h4 className="font-semibold text-[var(--text-primary)]">
{t('onboarding:bakery_type.selected_info_title', 'Perfecto para tu panadería')} {t('onboarding:bakery_type.selected_info_title', 'Perfecto para tu panadería')}
</h4> </h4>
<p className="text-sm text-text-secondary"> <p className="text-sm text-[var(--text-secondary)]">
{selectedType === 'production' && {selectedType === 'production' &&
t( t(
'onboarding:bakery_type.production.selected_info', 'onboarding:bakery_type.production.selected_info',
@@ -269,6 +253,27 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
</div> </div>
</div> </div>
)} )}
{/* Help Text & Continue Button */}
<div className="text-center space-y-4">
<p className="text-sm text-[var(--text-secondary)]">
{t(
'onboarding:bakery_type.help_text',
'💡 No te preocupes, siempre puedes cambiar esto más tarde en la configuración'
)}
</p>
<div className="flex justify-center pt-2">
<Button
onClick={handleContinue}
disabled={!selectedType}
size="lg"
className="w-full sm:w-auto sm:min-w-[200px]"
>
{t('onboarding:bakery_type.continue_button', 'Continuar')}
</Button>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,322 @@
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useClassifyBatch } from '../../../../api/hooks/inventory';
import { useValidateImportFile } from '../../../../api/hooks/sales';
import type { ImportValidationResponse } from '../../../../api/types/dataImport';
import type { ProductSuggestionResponse } from '../../../../api/types/inventory';
import { Upload, FileText, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
interface FileUploadStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data: {
uploadedFile: File; // NEW: Pass the file object for sales import
validationResult: ImportValidationResponse;
aiSuggestions: ProductSuggestionResponse[];
uploadedFileName: string;
uploadedFileSize: number;
}) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
interface ProgressState {
stage: 'preparing' | 'validating' | 'analyzing' | 'classifying';
progress: number;
message: string;
}
export const FileUploadStep: React.FC<FileUploadStepProps> = ({
onComplete,
onPrevious,
isFirstStep
}) => {
const { t } = useTranslation();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string>('');
const [progressState, setProgressState] = useState<ProgressState | null>(null);
const [showGuide, setShowGuide] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentTenant = useCurrentTenant();
// API hooks
const validateFileMutation = useValidateImportFile();
const classifyBatchMutation = useClassifyBatch();
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
setError('');
}
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) {
setSelectedFile(file);
setError('');
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
};
const handleUploadAndProcess = async () => {
if (!selectedFile || !currentTenant?.id) return;
setIsProcessing(true);
setError('');
setProgressState({
stage: 'preparing',
progress: 10,
message: t('onboarding:file_upload.preparing', 'Preparando archivo...')
});
try {
// Step 1: Validate the file
setProgressState({
stage: 'validating',
progress: 30,
message: t('onboarding:file_upload.validating', 'Validando formato del archivo...')
});
const validationResult = await validateFileMutation.mutateAsync({
tenantId: currentTenant.id,
file: selectedFile
});
if (!validationResult || validationResult.is_valid === undefined) {
throw new Error('Invalid validation response from server');
}
if (!validationResult.is_valid) {
const errorMsg = validationResult.errors?.join(', ') || 'Archivo inválido';
throw new Error(errorMsg);
}
// Step 2: Extract product list
setProgressState({
stage: 'analyzing',
progress: 50,
message: t('onboarding:file_upload.analyzing', 'Analizando productos en el archivo...')
});
const products = validationResult.product_list?.map((productName: string) => ({
product_name: productName
})) || [];
if (products.length === 0) {
throw new Error(t('onboarding:file_upload.no_products', 'No se encontraron productos en el archivo'));
}
// Step 3: AI Classification
setProgressState({
stage: 'classifying',
progress: 75,
message: t('onboarding:file_upload.classifying', `Clasificando ${products.length} productos con IA...`)
});
const classificationResponse = await classifyBatchMutation.mutateAsync({
tenantId: currentTenant.id,
products
});
// Step 4: Complete with success
setProgressState({
stage: 'classifying',
progress: 100,
message: t('onboarding:file_upload.success', '¡Análisis completado!')
});
// Pass data to parent and move to next step
setTimeout(() => {
onComplete({
uploadedFile: selectedFile, // NEW: Pass the file for sales import
validationResult,
aiSuggestions: classificationResponse.suggestions,
uploadedFileName: selectedFile.name,
uploadedFileSize: selectedFile.size,
});
}, 500);
} catch (err) {
console.error('Error processing file:', err);
setError(err instanceof Error ? err.message : 'Error procesando archivo');
setProgressState(null);
setIsProcessing(false);
}
};
const handleRemoveFile = () => {
setSelectedFile(null);
setError('');
setProgressState(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div>
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-2">
{t('onboarding:file_upload.title', 'Subir Datos de Ventas')}
</h2>
<p className="text-sm md:text-base text-[var(--text-secondary)]">
{t('onboarding:file_upload.description', 'Sube un archivo con tus datos de ventas y nuestro sistema detectará automáticamente tus productos')}
</p>
</div>
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-[var(--color-info)]" />
{t('setup_wizard:why_this_matters', '¿Por qué es importante?')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('onboarding:file_upload.why', 'Analizaremos tus datos de ventas históricas para configurar automáticamente tu inventario inicial con inteligencia artificial, ahorrándote horas de trabajo manual.')}
</p>
</div>
{/* File Upload Area */}
{!selectedFile && !isProcessing && (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className="border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg p-6 md:p-8 text-center hover:border-[var(--color-primary)]/50 transition-colors cursor-pointer min-h-[200px] flex flex-col items-center justify-center"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-10 h-10 md:w-12 md:h-12 mx-auto mb-3 md:mb-4 text-[var(--color-primary)]/50" />
<h3 className="text-base md:text-lg font-medium text-[var(--text-primary)] mb-2 px-4">
{t('onboarding:file_upload.drop_zone_title', 'Arrastra tu archivo aquí')}
</h3>
<p className="text-sm text-[var(--text-secondary)] mb-3 md:mb-4">
{t('onboarding:file_upload.drop_zone_subtitle', 'o haz clic para seleccionar')}
</p>
<p className="text-xs text-[var(--text-secondary)] px-4">
{t('onboarding:file_upload.formats', 'Formatos soportados: CSV, JSON (máx. 10MB)')}
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
onChange={handleFileSelect}
className="hidden"
/>
</div>
)}
{/* Selected File Preview */}
{selectedFile && !isProcessing && (
<div className="border border-[var(--color-success)] bg-[var(--color-success)]/5 rounded-lg p-3 md:p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
<FileText className="w-8 h-8 md:w-10 md:h-10 text-[var(--color-success)] flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium text-[var(--text-primary)] text-sm md:text-base truncate">{selectedFile.name}</p>
<p className="text-sm text-[var(--text-secondary)]">
{(selectedFile.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
<button
onClick={handleRemoveFile}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] p-2"
>
</button>
</div>
</div>
)}
{/* Progress Indicator */}
{isProcessing && progressState && (
<div className="border border-[var(--color-primary)] rounded-lg p-6 bg-[var(--color-primary)]/5">
<div className="flex items-center gap-3 mb-4">
<Loader2 className="w-6 h-6 animate-spin text-[var(--color-primary)]" />
<div className="flex-1">
<p className="font-medium text-[var(--text-primary)]">{progressState.message}</p>
<div className="mt-2 bg-[var(--bg-secondary)] rounded-full h-2">
<div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${progressState.progress}%` }}
/>
</div>
</div>
</div>
<p className="text-sm text-[var(--text-secondary)] text-center">
{progressState.progress}% completado
</p>
</div>
)}
{/* Error Display */}
{error && (
<div className="bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-[var(--color-danger)] flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-[var(--color-danger)] mb-1">Error</p>
<p className="text-sm text-[var(--text-secondary)]">{error}</p>
</div>
</div>
</div>
)}
{/* Help Guide Toggle */}
<button
onClick={() => setShowGuide(!showGuide)}
className="text-sm text-[var(--color-primary)] hover:underline"
>
{showGuide ? '▼' : '▶'} {t('onboarding:file_upload.show_guide', '¿Necesitas ayuda con el formato del archivo?')}
</button>
{showGuide && (
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 text-sm space-y-2">
<p className="font-medium text-[var(--text-primary)]">
{t('onboarding:file_upload.guide_title', 'Formato requerido del archivo:')}
</p>
<ul className="list-disc list-inside space-y-1 text-[var(--text-secondary)]">
<li>{t('onboarding:file_upload.guide_1', 'Columnas: Fecha, Producto, Cantidad')}</li>
<li>{t('onboarding:file_upload.guide_2', 'Formato de fecha: YYYY-MM-DD')}</li>
<li>{t('onboarding:file_upload.guide_3', 'Los nombres de productos deben ser consistentes')}</li>
<li>{t('onboarding:file_upload.guide_4', 'Ejemplo: 2024-01-15,Pan de Molde,25')}</li>
</ul>
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between gap-4 pt-6 border-t">
<Button
variant="outline"
onClick={onPrevious}
disabled={isProcessing || isFirstStep}
>
{t('common:back', '← Atrás')}
</Button>
<Button
onClick={handleUploadAndProcess}
disabled={!selectedFile || isProcessing}
className="min-w-[200px]"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('onboarding:file_upload.processing', 'Procesando...')}
</>
) : (
t('onboarding:file_upload.continue', 'Analizar y Continuar →')
)}
</Button>
</div>
</div>
);
};

View File

@@ -15,7 +15,7 @@ export interface ProductWithStock {
} }
export interface InitialStockEntryStepProps { export interface InitialStockEntryStepProps {
products: ProductWithStock[]; products?: ProductWithStock[]; // Made optional - will use empty array if not provided
onUpdate?: (data: { productsWithStock: ProductWithStock[] }) => void; onUpdate?: (data: { productsWithStock: ProductWithStock[] }) => void;
onComplete?: () => void; onComplete?: () => void;
onPrevious?: () => void; onPrevious?: () => void;
@@ -36,6 +36,10 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
if (initialData?.productsWithStock) { if (initialData?.productsWithStock) {
return initialData.productsWithStock; return initialData.productsWithStock;
} }
// Handle case where initialProducts is undefined (shouldn't happen, but defensive)
if (!initialProducts || initialProducts.length === 0) {
return [];
}
return initialProducts.map(p => ({ return initialProducts.map(p => ({
...p, ...p,
initialStock: p.initialStock ?? undefined, initialStock: p.initialStock ?? undefined,
@@ -78,17 +82,37 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
const productsWithStock = products.filter(p => p.initialStock !== undefined && p.initialStock >= 0); const productsWithStock = products.filter(p => p.initialStock !== undefined && p.initialStock >= 0);
const productsWithoutStock = products.filter(p => p.initialStock === undefined); const productsWithoutStock = products.filter(p => p.initialStock === undefined);
const completionPercentage = (productsWithStock.length / products.length) * 100; const completionPercentage = products.length > 0 ? (productsWithStock.length / products.length) * 100 : 100;
const allCompleted = productsWithoutStock.length === 0; const allCompleted = productsWithoutStock.length === 0;
// If no products, show a skip message
if (products.length === 0) {
return (
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-4 md:space-y-6">
<div className="text-center space-y-4">
<div className="text-6xl"></div>
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)]">
{t('onboarding:stock.no_products_title', 'Stock Inicial')}
</h2>
<p className="text-[var(--text-secondary)]">
{t('onboarding:stock.no_products_message', 'Podrás configurar los niveles de stock más tarde en la sección de inventario.')}
</p>
<Button onClick={handleContinue} variant="primary" rightIcon={<ArrowRight />}>
{t('common:continue', 'Continuar')}
</Button>
</div>
</div>
);
}
return ( return (
<div className="max-w-4xl mx-auto p-6 space-y-6"> <div className="max-w-4xl mx-auto p-4 md:p-6 space-y-4 md:space-y-6">
{/* Header */} {/* Header */}
<div className="text-center space-y-3"> <div className="text-center space-y-2 md:space-y-3">
<h1 className="text-2xl font-bold text-text-primary"> <h1 className="text-xl md:text-2xl font-bold text-text-primary px-2">
{t('onboarding:stock.title', 'Niveles de Stock Inicial')} {t('onboarding:stock.title', 'Niveles de Stock Inicial')}
</h1> </h1>
<p className="text-text-secondary max-w-2xl mx-auto"> <p className="text-sm md:text-base text-text-secondary max-w-2xl mx-auto px-4">
{t( {t(
'onboarding:stock.subtitle', 'onboarding:stock.subtitle',
'Ingresa las cantidades actuales de cada producto. Esto permite que el sistema rastree el inventario desde hoy.' 'Ingresa las cantidades actuales de cada producto. Esto permite que el sistema rastree el inventario desde hoy.'
@@ -133,11 +157,11 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="flex justify-between items-center"> <div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-2">
<Button onClick={handleSetAllToZero} variant="outline" size="sm"> <Button onClick={handleSetAllToZero} variant="outline" size="sm" className="w-full sm:w-auto">
{t('onboarding:stock.set_all_zero', 'Establecer todo a 0')} {t('onboarding:stock.set_all_zero', 'Establecer todo a 0')}
</Button> </Button>
<Button onClick={handleSkipForNow} variant="ghost" size="sm"> <Button onClick={handleSkipForNow} variant="ghost" size="sm" className="w-full sm:w-auto">
{t('onboarding:stock.skip_for_now', 'Omitir por ahora (se establecerá a 0)')} {t('onboarding:stock.skip_for_now', 'Omitir por ahora (se establecerá a 0)')}
</Button> </Button>
</div> </div>
@@ -178,7 +202,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
placeholder="0" placeholder="0"
min="0" min="0"
step="0.01" step="0.01"
className="w-24 text-right" className="w-20 sm:w-24 text-right min-h-[44px]"
/> />
<span className="text-sm text-text-secondary whitespace-nowrap"> <span className="text-sm text-text-secondary whitespace-nowrap">
{product.unit || 'kg'} {product.unit || 'kg'}

View File

@@ -0,0 +1,860 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateIngredient } from '../../../../api/hooks/inventory';
import { useImportSalesData } from '../../../../api/hooks/sales';
import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory';
import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory';
import { Package, ShoppingBag, AlertCircle, CheckCircle2, Edit2, Trash2, Plus, Sparkles } from 'lucide-react';
interface InventoryReviewStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data: {
inventoryItemsCreated: number;
salesDataImported: boolean;
}) => void;
isFirstStep: boolean;
isLastStep: boolean;
initialData?: {
uploadedFile?: File; // NEW: File object for sales import
validationResult?: any; // NEW: Validation result
aiSuggestions: ProductSuggestionResponse[];
uploadedFileName: string;
uploadedFileSize: number;
};
}
interface InventoryItemForm {
id: string; // Unique ID for UI tracking
name: string;
product_type: ProductType;
category: string;
unit_of_measure: UnitOfMeasure | string;
// AI suggestion metadata (if from AI)
isSuggested: boolean;
confidence_score?: number;
sales_data?: {
total_quantity: number;
average_daily_sales: number;
};
}
type FilterType = 'all' | 'ingredients' | 'finished_products';
// Template Definitions - Common Bakery Ingredients
interface TemplateItem {
name: string;
product_type: ProductType;
category: string;
unit_of_measure: UnitOfMeasure;
}
interface IngredientTemplate {
id: string;
name: string;
description: string;
icon: string;
items: TemplateItem[];
}
const INGREDIENT_TEMPLATES: IngredientTemplate[] = [
{
id: 'basic-bakery',
name: 'Ingredientes Básicos de Panadería',
description: 'Esenciales para cualquier panadería',
icon: '🍞',
items: [
{ name: 'Harina de Trigo', product_type: ProductType.INGREDIENT, category: IngredientCategory.FLOUR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Azúcar', product_type: ProductType.INGREDIENT, category: IngredientCategory.SUGAR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Sal', product_type: ProductType.INGREDIENT, category: IngredientCategory.SALT, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Levadura Fresca', product_type: ProductType.INGREDIENT, category: IngredientCategory.YEAST, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Agua', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.LITERS },
],
},
{
id: 'pastry-essentials',
name: 'Esenciales para Pastelería',
description: 'Ingredientes para pasteles y postres',
icon: '🎂',
items: [
{ name: 'Huevos', product_type: ProductType.INGREDIENT, category: IngredientCategory.EGGS, unit_of_measure: UnitOfMeasure.UNITS },
{ name: 'Mantequilla', product_type: ProductType.INGREDIENT, category: IngredientCategory.FATS, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Leche', product_type: ProductType.INGREDIENT, category: IngredientCategory.DAIRY, unit_of_measure: UnitOfMeasure.LITERS },
{ name: 'Vainilla', product_type: ProductType.INGREDIENT, category: IngredientCategory.SPICES, unit_of_measure: UnitOfMeasure.MILLILITERS },
{ name: 'Azúcar Glass', product_type: ProductType.INGREDIENT, category: IngredientCategory.SUGAR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
],
},
{
id: 'bread-basics',
name: 'Básicos para Pan Artesanal',
description: 'Todo lo necesario para pan artesanal',
icon: '🥖',
items: [
{ name: 'Harina Integral', product_type: ProductType.INGREDIENT, category: IngredientCategory.FLOUR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Masa Madre', product_type: ProductType.INGREDIENT, category: IngredientCategory.YEAST, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Aceite de Oliva', product_type: ProductType.INGREDIENT, category: IngredientCategory.FATS, unit_of_measure: UnitOfMeasure.LITERS },
{ name: 'Semillas de Sésamo', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
],
},
{
id: 'chocolate-specialties',
name: 'Especialidades de Chocolate',
description: 'Para productos con chocolate',
icon: '🍫',
items: [
{ name: 'Chocolate Negro', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Cacao en Polvo', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Chocolate con Leche', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Crema de Avellanas', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
],
},
];
export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
onComplete,
onPrevious,
isFirstStep,
initialData
}) => {
const { t } = useTranslation();
const [inventoryItems, setInventoryItems] = useState<InventoryItemForm[]>([]);
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState<InventoryItemForm>({
id: '',
name: '',
product_type: ProductType.INGREDIENT,
category: '',
unit_of_measure: UnitOfMeasure.KILOGRAMS,
isSuggested: false,
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// API hooks
const createIngredientMutation = useCreateIngredient();
const importSalesMutation = useImportSalesData();
// Initialize with AI suggestions
useEffect(() => {
if (initialData?.aiSuggestions) {
const items: InventoryItemForm[] = initialData.aiSuggestions.map((suggestion, index) => ({
id: `ai-${index}-${Date.now()}`,
name: suggestion.suggested_name,
product_type: suggestion.product_type as ProductType,
category: suggestion.category,
unit_of_measure: suggestion.unit_of_measure as UnitOfMeasure,
isSuggested: true,
confidence_score: suggestion.confidence_score,
sales_data: suggestion.sales_data ? {
total_quantity: suggestion.sales_data.total_quantity,
average_daily_sales: suggestion.sales_data.average_daily_sales,
} : undefined,
}));
setInventoryItems(items);
}
}, [initialData]);
// Filter items
const filteredItems = inventoryItems.filter(item => {
if (activeFilter === 'ingredients') return item.product_type === ProductType.INGREDIENT;
if (activeFilter === 'finished_products') return item.product_type === ProductType.FINISHED_PRODUCT;
return true;
});
// Count by type
const counts = {
all: inventoryItems.length,
ingredients: inventoryItems.filter(i => i.product_type === ProductType.INGREDIENT).length,
finished_products: inventoryItems.filter(i => i.product_type === ProductType.FINISHED_PRODUCT).length,
};
// Form handlers
const handleAdd = () => {
setFormData({
id: `manual-${Date.now()}`,
name: '',
product_type: ProductType.INGREDIENT,
category: '',
unit_of_measure: UnitOfMeasure.KILOGRAMS,
isSuggested: false,
});
setEditingId(null);
setIsAdding(true);
setFormErrors({});
};
const handleEdit = (item: InventoryItemForm) => {
setFormData({ ...item });
setEditingId(item.id);
setIsAdding(true);
setFormErrors({});
};
const handleDelete = (id: string) => {
setInventoryItems(items => items.filter(item => item.id !== id));
};
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.name.trim()) {
errors.name = t('validation:name_required', 'El nombre es requerido');
}
if (!formData.category) {
errors.category = t('validation:category_required', 'La categoría es requerida');
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = () => {
if (!validateForm()) return;
if (editingId) {
// Update existing
setInventoryItems(items =>
items.map(item => (item.id === editingId ? formData : item))
);
} else {
// Add new
setInventoryItems(items => [...items, formData]);
}
setIsAdding(false);
setEditingId(null);
setFormData({
id: '',
name: '',
product_type: ProductType.INGREDIENT,
category: '',
unit_of_measure: UnitOfMeasure.KILOGRAMS,
isSuggested: false,
});
};
const handleCancel = () => {
setIsAdding(false);
setEditingId(null);
setFormErrors({});
};
const handleAddTemplate = (template: IngredientTemplate) => {
// Check for duplicates by name
const existingNames = new Set(inventoryItems.map(item => item.name.toLowerCase()));
const newItems = template.items
.filter(item => !existingNames.has(item.name.toLowerCase()))
.map((item, index) => ({
id: `template-${template.id}-${index}-${Date.now()}`,
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure,
isSuggested: false,
}));
if (newItems.length > 0) {
setInventoryItems(items => [...items, ...newItems]);
}
};
const handleCompleteStep = async () => {
if (inventoryItems.length === 0) {
setFormErrors({ submit: t('validation:min_items', 'Agrega al menos 1 producto para continuar') });
return;
}
setIsSubmitting(true);
setFormErrors({});
try {
// STEP 1: Create all inventory items in parallel
// This MUST happen BEFORE sales import because sales records reference inventory IDs
console.log('📦 Creating inventory items...', inventoryItems.length);
console.log('📋 Items to create:', inventoryItems.map(item => ({
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure
})));
const createPromises = inventoryItems.map((item, index) => {
const ingredientData: IngredientCreate = {
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure as UnitOfMeasure,
// All other fields are optional now!
};
console.log(`🔄 Creating ingredient ${index + 1}/${inventoryItems.length}:`, ingredientData);
return createIngredientMutation.mutateAsync({
tenantId,
ingredientData,
}).catch(error => {
console.error(`❌ Failed to create ingredient "${item.name}":`, error);
console.error('Failed ingredient data:', ingredientData);
throw error;
});
});
await Promise.all(createPromises);
console.log('✅ Inventory items created successfully');
// STEP 2: Import sales data (only if file was uploaded)
// Now that inventory exists, sales records can reference the inventory IDs
let salesImported = false;
if (initialData?.uploadedFile && tenantId) {
try {
console.log('📊 Importing sales data from file:', initialData.uploadedFileName);
await importSalesMutation.mutateAsync({
tenantId,
file: initialData.uploadedFile,
});
salesImported = true;
console.log('✅ Sales data imported successfully');
} catch (salesError) {
console.error('⚠️ Sales import failed (non-blocking):', salesError);
// Don't block onboarding if sales import fails
// Inventory is already created, which is the critical part
}
}
// Complete the step with metadata
onComplete({
inventoryItemsCreated: inventoryItems.length,
salesDataImported: salesImported,
});
} catch (error) {
console.error('Error creating inventory items:', error);
setFormErrors({ submit: t('error:creating_items', 'Error al crear los productos. Inténtalo de nuevo.') });
setIsSubmitting(false);
}
};
// Category options based on product type
const getCategoryOptions = (productType: ProductType) => {
if (productType === ProductType.INGREDIENT) {
return Object.values(IngredientCategory).map(cat => ({
value: cat,
label: t(`inventory:enums.ingredient_category.${cat}`, cat)
}));
} else {
return Object.values(ProductCategory).map(cat => ({
value: cat,
label: t(`inventory:enums.product_category.${cat}`, cat)
}));
}
};
const unitOptions = Object.values(UnitOfMeasure).map(unit => ({
value: unit,
label: t(`inventory:enums.unit_of_measure.${unit}`, unit)
}));
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-2">
{t('onboarding:inventory_review.title', 'Revisar Inventario')}
</h2>
<p className="text-sm md:text-base text-[var(--text-secondary)]">
{t('onboarding:inventory_review.description', 'Revisa y ajusta los productos detectados. Puedes editar, eliminar o agregar más productos.')}
</p>
</div>
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-[var(--color-info)]" />
{t('setup_wizard:why_this_matters', '¿Por qué es importante?')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('onboarding:inventory_review.why', 'Estos productos serán la base de tu sistema. Diferenciamos entre Ingredientes (lo que usas para producir) y Productos Terminados (lo que vendes).')}
</p>
</div>
{/* Quick Add Templates */}
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-900/10 dark:to-blue-900/10 border border-purple-200 dark:border-purple-700 rounded-lg p-5">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<h3 className="font-semibold text-[var(--text-primary)]">
{t('inventory:templates.title', 'Plantillas de Ingredientes')}
</h3>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-4">
{t('inventory:templates.description', 'Agrega ingredientes comunes con un solo clic. Solo se agregarán los que no tengas ya.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{INGREDIENT_TEMPLATES.map((template) => (
<button
key={template.id}
onClick={() => handleAddTemplate(template)}
className="text-left p-4 bg-white dark:bg-gray-800 border-2 border-purple-200 dark:border-purple-700 rounded-lg hover:border-purple-400 dark:hover:border-purple-500 hover:shadow-md transition-all group"
>
<div className="flex items-start gap-3">
<span className="text-3xl group-hover:scale-110 transition-transform">{template.icon}</span>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-[var(--text-primary)] mb-1">
{template.name}
</h4>
<p className="text-xs text-[var(--text-secondary)] mb-2">
{template.description}
</p>
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">
{template.items.length} {t('inventory:templates.items', 'ingredientes')}
</p>
</div>
</div>
</button>
))}
</div>
</div>
{/* Filter Tabs */}
<div className="flex gap-2 border-b border-[var(--border-color)] overflow-x-auto scrollbar-hide -mx-2 px-2">
<button
onClick={() => setActiveFilter('all')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative whitespace-nowrap text-sm md:text-base ${
activeFilter === 'all'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
{t('inventory:filter.all', 'Todos')} ({counts.all})
</button>
<button
onClick={() => setActiveFilter('finished_products')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
activeFilter === 'finished_products'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
<ShoppingBag className="w-5 h-5" />
<span className="hidden sm:inline">{t('inventory:filter.finished_products', 'Productos Terminados')}</span>
<span className="sm:hidden">{t('inventory:filter.finished_products_short', 'Productos')}</span> ({counts.finished_products})
</button>
<button
onClick={() => setActiveFilter('ingredients')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
activeFilter === 'ingredients'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
<Package className="w-5 h-5" />
{t('inventory:filter.ingredients', 'Ingredientes')} ({counts.ingredients})
</button>
</div>
{/* Inventory List */}
<div className="space-y-3">
{filteredItems.length === 0 && (
<div className="text-center py-8 text-[var(--text-secondary)]">
{activeFilter === 'all'
? t('inventory:empty_state', 'No hay productos. Agrega uno para comenzar.')
: t('inventory:no_results', 'No hay productos de este tipo.')}
</div>
)}
{filteredItems.map((item) => (
<React.Fragment key={item.id}>
{/* Item Card */}
<div className="p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h5 className="font-medium text-[var(--text-primary)] truncate">{item.name}</h5>
{/* Product Type Badge */}
<span className={`text-xs px-2 py-0.5 rounded-full ${
item.product_type === ProductType.FINISHED_PRODUCT
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
{item.product_type === ProductType.FINISHED_PRODUCT ? (
<span className="flex items-center gap-1">
<ShoppingBag className="w-3 h-3" />
{t('inventory:type.finished_product', 'Producto')}
</span>
) : (
<span className="flex items-center gap-1">
<Package className="w-3 h-3" />
{t('inventory:type.ingredient', 'Ingrediente')}
</span>
)}
</span>
{/* AI Suggested Badge */}
{item.isSuggested && item.confidence_score && (
<span className="px-2 py-0.5 rounded text-xs bg-purple-100 text-purple-800">
IA {Math.round(item.confidence_score * 100)}%
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
<span>{item.product_type === ProductType.INGREDIENT ? t(`inventory:enums.ingredient_category.${item.category}`, item.category) : t(`inventory:enums.product_category.${item.category}`, item.category)}</span>
<span></span>
<span>{t(`inventory:enums.unit_of_measure.${item.unit_of_measure}`, item.unit_of_measure)}</span>
{item.sales_data && (
<>
<span></span>
<span>{t('inventory:sales_avg', 'Ventas')}: {item.sales_data.average_daily_sales.toFixed(1)}/día</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-2 md:ml-4">
<button
onClick={() => handleEdit(item)}
className="p-2 md:p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('common:edit', 'Editar')}
aria-label={t('common:edit', 'Editar')}
>
<Edit2 className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 md:p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('common:delete', 'Eliminar')}
aria-label={t('common:delete', 'Eliminar')}
>
<Trash2 className="w-5 h-5 md:w-4 md:h-4" />
</button>
</div>
</div>
</div>
{/* Inline Edit Form - appears right below the card being edited */}
{editingId === item.id && (
<div className="border-2 border-[var(--color-primary)] rounded-lg p-3 md:p-4 bg-[var(--bg-secondary)] ml-0 md:ml-4 mt-2">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-[var(--text-primary)]">
{t('inventory:edit_item', 'Editar Producto')}
</h3>
<button
type="button"
onClick={handleCancel}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancelar')}
</button>
</div>
<div className="space-y-4">
{/* Product Type Selector */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-3">
{t('inventory:product_type', 'Tipo de Producto')} *
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.INGREDIENT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<Package className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.ingredient', 'Ingrediente')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.ingredient_desc', 'Materias primas para producir')}
</div>
</button>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.FINISHED_PRODUCT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<ShoppingBag className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.finished_product', 'Producto Terminado')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.finished_product_desc', 'Productos que vendes')}
</div>
</button>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:name', 'Nombre')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
placeholder={t('inventory:name_placeholder', 'Ej: Harina de trigo')}
/>
{formErrors.name && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.name}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:category', 'Categoría')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
<option value="">{t('common:select', 'Seleccionar...')}</option>
{getCategoryOptions(formData.product_type).map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{formErrors.category && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.category}</p>
)}
</div>
{/* Unit of Measure */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:unit_of_measure', 'Unidad de Medida')} *
</label>
<select
value={formData.unit_of_measure}
onChange={(e) => setFormData(prev => ({ ...prev, unit_of_measure: e.target.value as UnitOfMeasure }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
{unitOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Form Actions */}
<div className="flex justify-end pt-2">
<Button onClick={handleSave}>
<CheckCircle2 className="w-4 h-4 mr-2" />
{t('common:save', 'Guardar')}
</Button>
</div>
</div>
</div>
)}
</React.Fragment>
))}
</div>
{/* Add Button - hidden when adding or editing */}
{!isAdding && !editingId && (
<button
onClick={handleAdd}
className="w-full border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg p-4 md:p-4 hover:border-[var(--color-primary)]/50 transition-colors flex items-center justify-center gap-2 text-[var(--color-primary)] min-h-[44px] font-medium"
>
<Plus className="w-5 h-5" />
<span className="text-sm md:text-base">{t('inventory:add_item', 'Agregar Producto')}</span>
</button>
)}
{/* Add New Item Form - only shown when adding (not editing) */}
{isAdding && !editingId && (
<div className="border-2 border-[var(--color-primary)] rounded-lg p-3 md:p-4 bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-[var(--text-primary)]">
{t('inventory:add_item', 'Agregar Producto')}
</h3>
<button
type="button"
onClick={handleCancel}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancelar')}
</button>
</div>
<div className="space-y-4">
{/* Product Type Selector */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-3">
{t('inventory:product_type', 'Tipo de Producto')} *
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.INGREDIENT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<Package className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.ingredient', 'Ingrediente')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.ingredient_desc', 'Materias primas para producir')}
</div>
</button>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.FINISHED_PRODUCT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<ShoppingBag className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.finished_product', 'Producto Terminado')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.finished_product_desc', 'Productos que vendes')}
</div>
</button>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:name', 'Nombre')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
placeholder={t('inventory:name_placeholder', 'Ej: Harina de trigo')}
/>
{formErrors.name && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.name}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:category', 'Categoría')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
<option value="">{t('common:select', 'Seleccionar...')}</option>
{getCategoryOptions(formData.product_type).map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{formErrors.category && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.category}</p>
)}
</div>
{/* Unit of Measure */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:unit_of_measure', 'Unidad de Medida')} *
</label>
<select
value={formData.unit_of_measure}
onChange={(e) => setFormData(prev => ({ ...prev, unit_of_measure: e.target.value as UnitOfMeasure }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
{unitOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Form Actions */}
<div className="flex justify-end pt-2">
<Button onClick={handleSave}>
<CheckCircle2 className="w-4 h-4 mr-2" />
{t('common:add', 'Agregar')}
</Button>
</div>
</div>
</div>
)}
{/* Submit Error */}
{formErrors.submit && (
<div className="bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 rounded-lg p-4">
<p className="text-sm text-[var(--color-danger)]">{formErrors.submit}</p>
</div>
)}
{/* Summary */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<p className="text-sm text-[var(--text-secondary)]">
{t('inventory:summary', 'Resumen')}: {counts.finished_products} {t('inventory:finished_products', 'productos terminados')}, {counts.ingredients} {t('inventory:ingredients_count', 'ingredientes')}
</p>
</div>
{/* Navigation */}
<div className="flex flex-col-reverse sm:flex-row justify-between gap-3 sm:gap-4 pt-6 border-t">
<Button
variant="outline"
onClick={onPrevious}
disabled={isSubmitting || isFirstStep}
className="w-full sm:w-auto"
>
<span className="hidden sm:inline">{t('common:back', '← Atrás')}</span>
<span className="sm:hidden">{t('common:back', 'Atrás')}</span>
</Button>
<Button
onClick={handleCompleteStep}
disabled={inventoryItems.length === 0 || isSubmitting}
className="w-full sm:w-auto sm:min-w-[200px]"
>
{isSubmitting
? t('common:saving', 'Guardando...')
: <>
<span className="hidden md:inline">{t('common:continue', 'Continuar')} ({inventoryItems.length} {t('common:items', 'productos')}) </span>
<span className="md:hidden">{t('common:continue', 'Continuar')} ({inventoryItems.length}) </span>
</>
}
</Button>
</div>
</div>
);
};

View File

@@ -157,13 +157,13 @@ export const ProductionProcessesStep: React.FC<ProductionProcessesStepProps> = (
}; };
return ( return (
<div className="max-w-4xl mx-auto p-6 space-y-6"> <div className="max-w-4xl mx-auto p-4 md:p-6 space-y-4 md:space-y-6">
{/* Header */} {/* Header */}
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-text-primary"> <h1 className="text-xl md:text-2xl font-bold text-text-primary px-2">
{t('onboarding:processes.title', 'Procesos de Producción')} {t('onboarding:processes.title', 'Procesos de Producción')}
</h1> </h1>
<p className="text-text-secondary"> <p className="text-sm md:text-base text-text-secondary px-4">
{t( {t(
'onboarding:processes.subtitle', 'onboarding:processes.subtitle',
'Define los procesos que usas para transformar productos pre-elaborados en productos terminados' 'Define los procesos que usas para transformar productos pre-elaborados en productos terminados'

View File

@@ -153,8 +153,8 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 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="Nombre de la Panadería"
placeholder="Ingresa el nombre de tu panadería" placeholder="Ingresa el nombre de tu panadería"

View File

@@ -4,6 +4,12 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
// Core Onboarding Steps // Core Onboarding Steps
export { RegisterTenantStep } from './RegisterTenantStep'; export { RegisterTenantStep } from './RegisterTenantStep';
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
export { FileUploadStep } from './FileUploadStep';
export { InventoryReviewStep } from './InventoryReviewStep';
// Legacy (keep for now, will deprecate after testing)
export { UploadSalesDataStep } from './UploadSalesDataStep'; export { UploadSalesDataStep } from './UploadSalesDataStep';
// AI-Assisted Path Steps // AI-Assisted Path Steps

View File

@@ -1,372 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../../contexts/AuthContext';
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
import { StepProgress } from './components/StepProgress';
import { StepNavigation } from './components/StepNavigation';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import {
WelcomeStep,
SuppliersSetupStep,
InventorySetupStep,
RecipesSetupStep,
QualitySetupStep,
TeamSetupStep,
ReviewSetupStep,
CompletionStep
} from './steps';
// Step weights for weighted progress calculation
const STEP_WEIGHTS = {
'setup-welcome': 5, // 2 min (light)
'suppliers-setup': 10, // 5 min (moderate)
'inventory-items-setup': 20, // 10 min (heavy)
'recipes-setup': 20, // 10 min (heavy)
'quality-setup': 15, // 7 min (moderate)
'team-setup': 10, // 5 min (optional)
'setup-review': 5, // 2 min (light, informational)
'setup-completion': 5 // 2 min (light)
};
export interface SetupStepConfig {
id: string;
title: string;
description: string;
component: React.ComponentType<SetupStepProps>;
minRequired?: number; // Minimum items to proceed
isOptional?: boolean; // Can be skipped
estimatedMinutes?: number; // For UI display
weight: number; // For progress calculation
}
export interface SetupStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
onSkip?: () => void;
onUpdate?: (state: { itemsCount?: number; canContinue?: boolean }) => void;
isFirstStep: boolean;
isLastStep: boolean;
canContinue?: boolean;
}
export const SetupWizard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useAuth();
// Define setup wizard steps (Steps 5-11 in overall onboarding)
const SETUP_STEPS: SetupStepConfig[] = [
{
id: 'setup-welcome',
title: t('setup_wizard:steps.welcome.title', 'Welcome & Setup Overview'),
description: t('setup_wizard:steps.welcome.description', 'Let\'s set up your bakery operations'),
component: WelcomeStep,
isOptional: true,
estimatedMinutes: 2,
weight: STEP_WEIGHTS['setup-welcome']
},
{
id: 'suppliers-setup',
title: t('setup_wizard:steps.suppliers.title', 'Add Suppliers'),
description: t('setup_wizard:steps.suppliers.description', 'Your ingredient and material providers'),
component: SuppliersSetupStep,
minRequired: 1,
isOptional: false,
estimatedMinutes: 5,
weight: STEP_WEIGHTS['suppliers-setup']
},
{
id: 'inventory-items-setup',
title: t('setup_wizard:steps.inventory.title', 'Set Up Inventory Items'),
description: t('setup_wizard:steps.inventory.description', 'Ingredients and materials you use'),
component: InventorySetupStep,
minRequired: 3,
isOptional: false,
estimatedMinutes: 10,
weight: STEP_WEIGHTS['inventory-items-setup']
},
{
id: 'recipes-setup',
title: t('setup_wizard:steps.recipes.title', 'Create Recipes'),
description: t('setup_wizard:steps.recipes.description', 'Your bakery\'s production formulas'),
component: RecipesSetupStep,
minRequired: 1,
isOptional: false,
estimatedMinutes: 10,
weight: STEP_WEIGHTS['recipes-setup']
},
{
id: 'quality-setup',
title: t('setup_wizard:steps.quality.title', 'Define Quality Standards'),
description: t('setup_wizard:steps.quality.description', 'Standards for consistent production'),
component: QualitySetupStep,
minRequired: 2,
isOptional: true,
estimatedMinutes: 7,
weight: STEP_WEIGHTS['quality-setup']
},
{
id: 'team-setup',
title: t('setup_wizard:steps.team.title', 'Add Team Members'),
description: t('setup_wizard:steps.team.description', 'Your bakery staff'),
component: TeamSetupStep,
minRequired: 0,
isOptional: true,
estimatedMinutes: 5,
weight: STEP_WEIGHTS['team-setup']
},
{
id: 'setup-review',
title: t('setup_wizard:steps.review.title', 'Review Your Setup'),
description: t('setup_wizard:steps.review.description', 'Confirm your configuration'),
component: ReviewSetupStep,
isOptional: false,
estimatedMinutes: 2,
weight: STEP_WEIGHTS['setup-review']
},
{
id: 'setup-completion',
title: t('setup_wizard:steps.completion.title', 'You\'re All Set!'),
description: t('setup_wizard:steps.completion.description', 'Your bakery system is ready'),
component: CompletionStep,
isOptional: false,
estimatedMinutes: 2,
weight: STEP_WEIGHTS['setup-completion']
}
];
// State management
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isInitialized, setIsInitialized] = useState(false);
const [canContinue, setCanContinue] = useState(false);
// Handle updates from step components
const handleStepUpdate = (state: { itemsCount?: number; canContinue?: boolean }) => {
if (state.canContinue !== undefined) {
setCanContinue(state.canContinue);
}
};
// Get user progress from backend
const { data: userProgress, isLoading: isLoadingProgress } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted();
// Calculate weighted progress percentage
const calculateProgress = (): number => {
if (!userProgress) return 0;
const totalWeight = Object.values(STEP_WEIGHTS).reduce((a, b) => a + b);
let completedWeight = 0;
// Add weight of fully completed steps
SETUP_STEPS.forEach((step, index) => {
if (index < currentStepIndex) {
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
if (stepProgress?.completed) {
completedWeight += step.weight;
}
}
});
// Add 50% of current step weight (user is midway through)
const currentStep = SETUP_STEPS[currentStepIndex];
completedWeight += currentStep.weight * 0.5;
return Math.round((completedWeight / totalWeight) * 100);
};
const progressPercentage = calculateProgress();
// Initialize step index based on backend progress
useEffect(() => {
if (userProgress && !isInitialized) {
console.log('🔄 Initializing setup wizard progress:', userProgress);
// Find first incomplete step
let stepIndex = 0;
for (let i = 0; i < SETUP_STEPS.length; i++) {
const step = SETUP_STEPS[i];
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
if (!stepProgress?.completed && stepProgress?.status !== 'skipped') {
stepIndex = i;
console.log(`📍 Resuming at step: "${step.id}" (index ${i})`);
break;
}
}
// If all steps complete, go to last step
if (stepIndex === 0 && SETUP_STEPS.every(step => {
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
return stepProgress?.completed || stepProgress?.status === 'skipped';
})) {
stepIndex = SETUP_STEPS.length - 1;
console.log('✅ All steps completed, going to completion step');
}
setCurrentStepIndex(stepIndex);
setIsInitialized(true);
}
}, [userProgress, isInitialized]);
const currentStep = SETUP_STEPS[currentStepIndex];
// Navigation handlers
const handleNext = () => {
if (currentStepIndex < SETUP_STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
setCanContinue(false); // Reset for next step
}
};
const handlePrevious = () => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
}
};
const handleSkip = async () => {
if (!user?.id || !currentStep.isOptional) return;
console.log(`⏭️ Skipping step: "${currentStep.id}"`);
try {
// Mark step as skipped (not completed)
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: currentStep.id,
data: {
skipped: true,
skipped_at: new Date().toISOString()
}
});
console.log(`✅ Step "${currentStep.id}" marked as skipped`);
// Move to next step
handleNext();
} catch (error) {
console.error(`❌ Error skipping step "${currentStep.id}":`, error);
}
};
const handleStepComplete = async (data?: any) => {
if (!user?.id) {
console.error('User ID not available');
return;
}
// Prevent concurrent mutations
if (markStepCompleted.isPending) {
console.warn(`⚠️ Step completion already in progress for "${currentStep.id}"`);
return;
}
console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data);
try {
// Mark step as completed in backend
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: currentStep.id,
data: {
...data,
completed_at: new Date().toISOString()
}
});
console.log(`✅ Successfully completed step: "${currentStep.id}"`);
// Handle completion step navigation
if (currentStep.id === 'setup-completion') {
console.log('🎉 Setup wizard completed! Navigating to dashboard...');
navigate('/app/dashboard');
} else {
// Auto-advance to next step
handleNext();
}
} catch (error: any) {
console.error(`❌ Error completing step "${currentStep.id}":`, error);
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
alert(`${t('setup_wizard:errors.step_failed', 'Error completing step')} "${currentStep.title}": ${errorMessage}`);
}
};
// Show loading state while initializing
if (isLoadingProgress || !isInitialized) {
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="flex items-center justify-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)]">
{t('common:loading', 'Loading your setup progress...')}
</p>
</div>
</CardBody>
</Card>
</div>
);
}
const StepComponent = currentStep.component;
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 space-y-6">
{/* Progress Header */}
<StepProgress
steps={SETUP_STEPS}
currentStepIndex={currentStepIndex}
progressPercentage={progressPercentage}
userProgress={userProgress}
/>
{/* Step Content */}
<Card shadow="lg" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<div className="w-6 h-6 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold">
{currentStepIndex + 1}
</div>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
{currentStep.title}
</h2>
<p className="text-[var(--text-secondary)] text-sm">
{currentStep.description}
</p>
</div>
{currentStep.estimatedMinutes && (
<div className="hidden sm:block text-sm text-[var(--text-tertiary)]">
~{currentStep.estimatedMinutes} min
</div>
)}
</div>
</CardHeader>
<CardBody padding="lg">
<StepComponent
onNext={handleNext}
onPrevious={handlePrevious}
onComplete={handleStepComplete}
onSkip={handleSkip}
onUpdate={handleStepUpdate}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === SETUP_STEPS.length - 1}
canContinue={canContinue}
/>
</CardBody>
</Card>
</div>
);
};

View File

@@ -1,4 +1,4 @@
export { SetupWizard } from './SetupWizard'; // SetupWizard.tsx has been deleted - setup is now integrated into UnifiedOnboardingWizard
export type { SetupStepConfig, SetupStepProps } from './SetupWizard'; // Individual setup steps are still used by UnifiedOnboardingWizard
export * from './steps'; export * from './steps';
export * from './components'; export * from './components';

View File

@@ -14,8 +14,8 @@ const OnboardingPage: React.FC = () => {
variant: "minimal" variant: "minimal"
}} }}
> >
<div className="min-h-screen bg-[var(--bg-primary)] py-8"> <div className="min-h-screen bg-[var(--bg-primary)] py-4 md:py-8">
<div className="container mx-auto px-4"> <div className="container mx-auto px-2 sm:px-4">
<UnifiedOnboardingWizard /> <UnifiedOnboardingWizard />
</div> </div>
</div> </div>

View File

@@ -1,18 +0,0 @@
import React from 'react';
import { SetupWizard } from '../../components/domain/setup-wizard';
/**
* Setup Page - Wrapper for the Setup Wizard
* This page is accessed after completing the initial onboarding
* and guides users through setting up their bakery operations
* (suppliers, inventory, recipes, quality standards, team)
*/
const SetupPage: React.FC = () => {
return (
<div className="min-h-screen bg-[var(--bg-primary)]">
<SetupWizard />
</div>
);
};
export default SetupPage;

View File

@@ -57,9 +57,8 @@ const ModelsConfigPage = React.lazy(() => import('../pages/app/database/models/M
const QualityTemplatesPage = React.lazy(() => import('../pages/app/database/quality-templates/QualityTemplatesPage')); const QualityTemplatesPage = React.lazy(() => import('../pages/app/database/quality-templates/QualityTemplatesPage'));
const SustainabilityPage = React.lazy(() => import('../pages/app/database/sustainability/SustainabilityPage')); const SustainabilityPage = React.lazy(() => import('../pages/app/database/sustainability/SustainabilityPage'));
// Onboarding & Setup pages // Onboarding page (Setup is now integrated into UnifiedOnboardingWizard)
const OnboardingPage = React.lazy(() => import('../pages/onboarding/OnboardingPage')); const OnboardingPage = React.lazy(() => import('../pages/onboarding/OnboardingPage'));
const SetupPage = React.lazy(() => import('../pages/setup/SetupPage'));
export const AppRouter: React.FC = () => { export const AppRouter: React.FC = () => {
return ( return (
@@ -389,17 +388,7 @@ export const AppRouter: React.FC = () => {
} }
/> />
{/* Setup Wizard Route - Protected with AppShell */} {/* Setup is now integrated into UnifiedOnboardingWizard */}
<Route
path="/app/setup"
element={
<ProtectedRoute>
<AppShell>
<SetupPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Default redirects */} {/* Default redirects */}
<Route path="/app/*" element={<Navigate to="/app/dashboard" replace />} /> <Route path="/app/*" element={<Navigate to="/app/dashboard" replace />} />

View File

@@ -165,9 +165,8 @@ export const ROUTES = {
HELP_SUPPORT: '/help/support', HELP_SUPPORT: '/help/support',
HELP_FEEDBACK: '/help/feedback', HELP_FEEDBACK: '/help/feedback',
// Onboarding & Setup // Onboarding (Setup is now integrated into UnifiedOnboardingWizard)
ONBOARDING: '/app/onboarding', ONBOARDING: '/app/onboarding',
SETUP: '/app/setup',
// Error pages // Error pages
NOT_FOUND: '/404', NOT_FOUND: '/404',
@@ -575,22 +574,7 @@ export const routesConfig: RouteConfig[] = [
}, },
}, },
// Setup Wizard - Bakery operations setup (post-onboarding) // Setup is now integrated into UnifiedOnboardingWizard - route removed
{
path: '/app/setup',
name: 'Setup',
component: 'SetupPage',
title: 'Configurar Operaciones',
description: 'Configure suppliers, inventory, recipes, and quality standards',
icon: 'settings',
requiresAuth: true,
showInNavigation: false,
meta: {
hideHeader: false, // Show header for easy navigation
hideSidebar: false, // Show sidebar for context
fullScreen: false,
},
},
// Error pages // Error pages

View File

@@ -49,12 +49,15 @@ ONBOARDING_STEPS = [
# Phase 2: Core Setup # Phase 2: Core Setup
"setup", # Basic bakery setup and tenant creation "setup", # Basic bakery setup and tenant creation
# Phase 2a: AI-Assisted Path (ONLY PATH - manual path removed) # Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
"smart-inventory-setup", # Sales data upload and AI analysis "upload-sales-data", # File upload, validation, and AI classification
"product-categorization", # Categorize products as ingredients vs finished products "inventory-review", # Review and confirm AI-detected products with type selection
"initial-stock-entry", # Capture initial stock levels "initial-stock-entry", # Capture initial stock levels
# Phase 2b: Suppliers (shared by all paths) # Phase 2b: Product Categorization (optional advanced categorization)
"product-categorization", # Advanced categorization (may be deprecated)
# Phase 2c: Suppliers (shared by all paths)
"suppliers-setup", # Suppliers configuration "suppliers-setup", # Suppliers configuration
# Phase 3: Advanced Configuration (all optional) # Phase 3: Advanced Configuration (all optional)
@@ -78,13 +81,16 @@ STEP_DEPENDENCIES = {
# Core setup - no longer depends on data-source-choice (removed) # Core setup - no longer depends on data-source-choice (removed)
"setup": ["user_registered", "bakery-type-selection"], "setup": ["user_registered", "bakery-type-selection"],
# AI-Assisted path dependencies (ONLY path now) # AI-Assisted Inventory Setup - REFACTORED into 3 sequential steps
"smart-inventory-setup": ["user_registered", "setup"], "upload-sales-data": ["user_registered", "setup"],
"product-categorization": ["user_registered", "setup", "smart-inventory-setup"], "inventory-review": ["user_registered", "setup", "upload-sales-data"],
"initial-stock-entry": ["user_registered", "setup", "smart-inventory-setup", "product-categorization"], "initial-stock-entry": ["user_registered", "setup", "upload-sales-data", "inventory-review"],
# Suppliers (after AI inventory setup) # Advanced product categorization (optional, may be deprecated)
"suppliers-setup": ["user_registered", "setup", "smart-inventory-setup"], "product-categorization": ["user_registered", "setup", "upload-sales-data"],
# Suppliers (after inventory review)
"suppliers-setup": ["user_registered", "setup", "inventory-review"],
# Advanced configuration (optional, minimal dependencies) # Advanced configuration (optional, minimal dependencies)
"recipes-setup": ["user_registered", "setup"], "recipes-setup": ["user_registered", "setup"],
@@ -92,8 +98,8 @@ STEP_DEPENDENCIES = {
"quality-setup": ["user_registered", "setup"], "quality-setup": ["user_registered", "setup"],
"team-setup": ["user_registered", "setup"], "team-setup": ["user_registered", "setup"],
# ML Training - requires AI path completion # ML Training - requires AI path completion (upload-sales-data with inventory review)
"ml-training": ["user_registered", "setup", "smart-inventory-setup"], "ml-training": ["user_registered", "setup", "upload-sales-data", "inventory-review"],
# Review and completion # Review and completion
"setup-review": ["user_registered", "setup"], "setup-review": ["user_registered", "setup"],
@@ -277,20 +283,24 @@ class OnboardingService:
# SPECIAL VALIDATION FOR ML TRAINING STEP # SPECIAL VALIDATION FOR ML TRAINING STEP
if step_name == "ml-training": if step_name == "ml-training":
# ML training requires AI-assisted path completion (only path available now) # ML training requires AI-assisted path completion
ai_path_complete = user_progress_data.get("smart-inventory-setup", {}).get("completed", False) # Check if upload-sales-data and inventory-review are completed
upload_complete = user_progress_data.get("upload-sales-data", {}).get("completed", False)
inventory_complete = user_progress_data.get("inventory-review", {}).get("completed", False)
if ai_path_complete: if upload_complete and inventory_complete:
# Validate sales data was imported # Validate sales data was imported
smart_inventory_data = user_progress_data.get("smart-inventory-setup", {}).get("data", {}) upload_data = user_progress_data.get("upload-sales-data", {}).get("data", {})
sales_import_result = smart_inventory_data.get("salesImportResult", {}) inventory_data = user_progress_data.get("inventory-review", {}).get("data", {})
has_sales_data_imported = (
sales_import_result.get("records_created", 0) > 0 or # Check if sales data was processed
sales_import_result.get("success", False) or has_sales_data = (
sales_import_result.get("imported", False) upload_data.get("validationResult", {}).get("is_valid", False) or
upload_data.get("aiSuggestions", []) or
inventory_data.get("inventoryItemsCreated", 0) > 0
) )
if has_sales_data_imported: if has_sales_data:
logger.info(f"ML training allowed for user {user_id}: AI path with sales data") logger.info(f"ML training allowed for user {user_id}: AI path with sales data")
return True return True

View File

@@ -114,10 +114,11 @@ class Ingredient(Base):
last_purchase_price = Column(Numeric(10, 2), nullable=True) last_purchase_price = Column(Numeric(10, 2), nullable=True)
standard_cost = Column(Numeric(10, 2), nullable=True) standard_cost = Column(Numeric(10, 2), nullable=True)
# Stock management # Stock management - now optional to simplify onboarding
low_stock_threshold = Column(Float, nullable=False, default=10.0) # These can be configured later based on actual usage patterns
reorder_point = Column(Float, nullable=False, default=20.0) low_stock_threshold = Column(Float, nullable=True, default=None)
reorder_quantity = Column(Float, nullable=False, default=50.0) reorder_point = Column(Float, nullable=True, default=None)
reorder_quantity = Column(Float, nullable=True, default=None)
max_stock_level = Column(Float, nullable=True) max_stock_level = Column(Float, nullable=True)
# Shelf life (critical for finished products) - default values only # Shelf life (critical for finished products) - default values only

View File

@@ -46,12 +46,14 @@ class IngredientCreate(InventoryBaseSchema):
# Pricing # Pricing
# Note: average_cost is calculated automatically from purchases (not set on create) # Note: average_cost is calculated automatically from purchases (not set on create)
# All cost fields are optional - can be added later after onboarding
standard_cost: Optional[Decimal] = Field(None, ge=0, description="Standard/target cost per unit for budgeting") standard_cost: Optional[Decimal] = Field(None, ge=0, description="Standard/target cost per unit for budgeting")
# Stock management # Stock management - all optional with sensible defaults for onboarding
low_stock_threshold: float = Field(10.0, ge=0, description="Low stock alert threshold") # These can be configured later based on actual usage patterns
reorder_point: float = Field(20.0, ge=0, description="Reorder point") low_stock_threshold: Optional[float] = Field(None, ge=0, description="Low stock alert threshold")
reorder_quantity: float = Field(50.0, gt=0, description="Default reorder quantity") reorder_point: Optional[float] = Field(None, ge=0, description="Reorder point")
reorder_quantity: Optional[float] = Field(None, gt=0, description="Default reorder quantity")
max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level") max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level")
# Shelf life (default value only - actual per batch) # Shelf life (default value only - actual per batch)
@@ -67,8 +69,15 @@ class IngredientCreate(InventoryBaseSchema):
@validator('reorder_point') @validator('reorder_point')
def validate_reorder_point(cls, v, values): def validate_reorder_point(cls, v, values):
if 'low_stock_threshold' in values and v <= values['low_stock_threshold']: # Only validate if both values are provided and not None
raise ValueError('Reorder point must be greater than low stock threshold') low_stock = values.get('low_stock_threshold')
if v is not None and low_stock is not None:
try:
if v <= low_stock:
raise ValueError('Reorder point must be greater than low stock threshold')
except TypeError:
# Skip validation if comparison fails due to type mismatch
pass
return v return v
@@ -125,9 +134,9 @@ class IngredientResponse(InventoryBaseSchema):
average_cost: Optional[float] average_cost: Optional[float]
last_purchase_price: Optional[float] last_purchase_price: Optional[float]
standard_cost: Optional[float] standard_cost: Optional[float]
low_stock_threshold: float low_stock_threshold: Optional[float] # Now optional
reorder_point: float reorder_point: Optional[float] # Now optional
reorder_quantity: float reorder_quantity: Optional[float] # Now optional
max_stock_level: Optional[float] max_stock_level: Optional[float]
shelf_life_days: Optional[int] # Default value only shelf_life_days: Optional[int] # Default value only
is_active: bool is_active: bool
@@ -209,9 +218,15 @@ class StockCreate(InventoryBaseSchema):
@validator('storage_temperature_max') @validator('storage_temperature_max')
def validate_temperature_range(cls, v, values): def validate_temperature_range(cls, v, values):
# Only validate if both values are provided and not None
min_temp = values.get('storage_temperature_min') min_temp = values.get('storage_temperature_min')
if v is not None and min_temp is not None and v <= min_temp: if v is not None and min_temp is not None:
raise ValueError('Max temperature must be greater than min temperature') try:
if v <= min_temp:
raise ValueError('Max temperature must be greater than min temperature')
except TypeError:
# Skip validation if comparison fails due to type mismatch
pass
return v return v
class StockUpdate(InventoryBaseSchema): class StockUpdate(InventoryBaseSchema):

View File

@@ -1062,9 +1062,12 @@ class InventoryService:
async def _validate_ingredient_data(self, ingredient_data: IngredientCreate, tenant_id: UUID): async def _validate_ingredient_data(self, ingredient_data: IngredientCreate, tenant_id: UUID):
"""Validate ingredient data for business rules""" """Validate ingredient data for business rules"""
# Add business validation logic here # Only validate reorder_point if both values are provided
if ingredient_data.reorder_point <= ingredient_data.low_stock_threshold: # During onboarding, these fields may be None, which is valid
raise ValueError("Reorder point must be greater than low stock threshold") if (ingredient_data.reorder_point is not None and
ingredient_data.low_stock_threshold is not None):
if ingredient_data.reorder_point <= ingredient_data.low_stock_threshold:
raise ValueError("Reorder point must be greater than low stock threshold")
# Storage requirements validation moved to stock level (not ingredient level) # Storage requirements validation moved to stock level (not ingredient level)
# This is now handled in stock creation/update validation # This is now handled in stock creation/update validation

View File

@@ -0,0 +1,84 @@
"""make_stock_management_fields_nullable
Revision ID: make_stock_fields_nullable
Revises: add_local_production_support
Create Date: 2025-11-08 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'make_stock_fields_nullable'
down_revision = 'add_local_production_support'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Make stock management fields nullable to simplify onboarding
These fields (low_stock_threshold, reorder_point, reorder_quantity) are now optional
during onboarding and can be configured later based on actual usage patterns.
"""
# Make low_stock_threshold nullable
op.alter_column('ingredients', 'low_stock_threshold',
existing_type=sa.Float(),
nullable=True,
existing_nullable=False)
# Make reorder_point nullable
op.alter_column('ingredients', 'reorder_point',
existing_type=sa.Float(),
nullable=True,
existing_nullable=False)
# Make reorder_quantity nullable
op.alter_column('ingredients', 'reorder_quantity',
existing_type=sa.Float(),
nullable=True,
existing_nullable=False)
def downgrade() -> None:
"""Revert stock management fields to NOT NULL
WARNING: This will fail if any records have NULL values in these fields.
You must set default values before running this downgrade.
"""
# Set default values for any NULL records before making fields NOT NULL
op.execute("""
UPDATE ingredients
SET low_stock_threshold = 10.0
WHERE low_stock_threshold IS NULL
""")
op.execute("""
UPDATE ingredients
SET reorder_point = 20.0
WHERE reorder_point IS NULL
""")
op.execute("""
UPDATE ingredients
SET reorder_quantity = 50.0
WHERE reorder_quantity IS NULL
""")
# Make fields NOT NULL again
op.alter_column('ingredients', 'low_stock_threshold',
existing_type=sa.Float(),
nullable=False,
existing_nullable=True)
op.alter_column('ingredients', 'reorder_point',
existing_type=sa.Float(),
nullable=False,
existing_nullable=True)
op.alter_column('ingredients', 'reorder_quantity',
existing_type=sa.Float(),
nullable=False,
existing_nullable=True)

57
todo.md Normal file
View File

@@ -0,0 +1,57 @@
Role
Act as a senior product strategist and UX designer with deep expertise in Jobs To Be Done (JTBD) methodology and user-centered design. You think in terms of user goals, progress-making moments, and unmet needs — similar to approaches used at companies like Intercom, Basecamp, or IDEO.
Context
You are helping a product team break down a broad user or business problem into a structured map of Jobs To Be Done. This decomposition will guide discovery, prioritization, and solution design.
Task & Instructions
The user needs to add new any type of new contet to the bakery in the easiest posible way.
Thebakery has those items:
1- Add new ingredients to the bakery inventory list
2- Add new suppliers to the bakery supplier list
3- Add new recipe to the bakery recipe list
4- Add new equipment to the bakery equipment list
5- Add new quality templates to the bakery quality templates list
6- Add new customer orders to the order list
7- Add new customers to the customer list
8- Add new team member to the bakery team
9- Add new sales records to teh bakery
The idea is to have a unique button that opens and wizard of step by step modal. The first step must to answer the queston of what teh user needs to add from the given list of 9 items.
Each item , once selected, must have a component that ows the setp-by step guide for that particular item.
1- The inventory must have teh following steps: select which type of inventory ingredient or finished product-> add must-have conetnt to that ingredients-> add initial lot or lots to the stock with the critical content needed.
2- The supplier step must contain the step to include supplier information-> ingredient that lhis supplie povides form teh inventory and the price in which you buy it and the minimun order quantities.
3- The recipe must have an step by step guide with the recipe information -> the ingredient need from teh inventory list-> aand the quality templates that apply to this recipes
4- the equipoment steps.
5- Thecustomer orders steps
6- The customers add steps.
7- Add team member steps.
8- Add qualite tempalte configuration steps.
9- Add manual entry of sales or upload of file for chunch upload step-> and then the step to add the sales. This is very crtitical as most the the small bakaery may do not have a POS system and collect the sales manually or in and excel file. So , in order to keep our system consuming the new historical and current sales, this add sales step is very important to design it porperly form teh UX and UI prespective.
The individual button that nowdays exist in the project in each page as inventory, sypplier, equipment, must open the new step -by step component related to taht particular block.
Use JTBD thinking to uncover:
The main functional job the user is trying to get done;
Related emotional or social jobs;
Sub-jobs or tasks users must complete along the way;
Forces of progress and barriers that influence behavior.
Checkpoints Before finalizing, check yourself:
Are the jobs clearly goal-oriented and not solution-oriented?
Are sub-jobs specific steps toward the main job?
Are emotional/social jobs captured?
Are user struggles or unmet needs listed?
If anythings missing or unclear, revise and explain what was added or changed.