IMPORVE ONBOARDING STEPS
This commit is contained in:
877
ARCHITECTURE_ANALYSIS.md
Normal file
877
ARCHITECTURE_ANALYSIS.md
Normal 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
315
REFACTORING_ROADMAP.md
Normal 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
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
57
todo.md
Normal 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 anything’s missing or unclear, revise and explain what was added or changed.
|
||||||
Reference in New Issue
Block a user