2025-09-08 17:19:00 +02:00
|
|
|
import React, { useState, useRef } from 'react';
|
2025-10-19 19:22:37 +02:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2025-09-08 17:19:00 +02:00
|
|
|
import { Button } from '../../../ui/Button';
|
|
|
|
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
import { useCreateIngredient, useClassifyBatch, useAddStock } from '../../../../api/hooks/inventory';
|
2025-10-06 15:27:01 +02:00
|
|
|
import { useValidateImportFile, useImportSalesData } from '../../../../api/hooks/sales';
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
import { useSuppliers } from '../../../../api/hooks/suppliers';
|
2025-10-19 19:22:37 +02:00
|
|
|
import type { ImportValidationResponse } from '../../../../api/types/dataImport';
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
import type { ProductSuggestionResponse, StockCreate, StockResponse } from '../../../../api/types/inventory';
|
|
|
|
|
import { ProductionStage } from '../../../../api/types/inventory';
|
2025-09-08 22:28:26 +02:00
|
|
|
import { useAuth } from '../../../../contexts/AuthContext';
|
Implement 5 UX enhancements for ingredient management
This commit implements the requested enhancements for the ingredient
quick-add system and batch management:
**1. Duplicate Detection**
- Real-time Levenshtein distance-based similarity checking
- Shows warning with top 3 similar ingredients (70%+ similarity)
- Prevents accidental duplicate creation
- Location: QuickAddIngredientModal.tsx
**2. Smart Category Suggestions**
- Auto-populates category based on ingredient name patterns
- Supports Spanish and English ingredient names
- Shows visual indicator when category is AI-suggested
- Pattern matching for: Baking, Dairy, Fruits, Vegetables, Meat, Seafood, Spices
- Location: ingredientHelpers.ts
**3. Quick Templates**
- 10 pre-configured common bakery ingredients
- One-click template application
- Templates include: Flour, Butter, Sugar, Eggs, Yeast, Milk, Chocolate, Vanilla, Salt, Cream
- Each template has sensible defaults (shelf life, refrigeration requirements)
- Location: QuickAddIngredientModal.tsx
**4. Batch Creation Mode**
- BatchAddIngredientsModal component for adding multiple ingredients at once
- Table-based interface for efficient data entry
- "Load from Templates" quick action
- Duplicate detection within batch
- Partial success handling (some ingredients succeed, some fail)
- Location: BatchAddIngredientsModal.tsx
- Integration: UploadSalesDataStep.tsx (2 buttons: "Add One" / "Add Multiple")
**5. Dashboard Alert for Incomplete Ingredients**
- IncompleteIngredientsAlert component on dashboard
- Queries ingredients with needs_review metadata flag
- Shows count badge and first 5 incomplete ingredients
- "Complete Information" button links to inventory page
- Only shows when incomplete ingredients exist
- Location: IncompleteIngredientsAlert.tsx
- Integration: DashboardPage.tsx
**New Files Created:**
- ingredientHelpers.ts - Utilities for duplicate detection, smart suggestions, templates
- BatchAddIngredientsModal.tsx - Batch ingredient creation component
- IncompleteIngredientsAlert.tsx - Dashboard alert component
**Files Modified:**
- QuickAddIngredientModal.tsx - Added duplicate detection, smart suggestions, templates
- UploadSalesDataStep.tsx - Integrated batch creation modal
- DashboardPage.tsx - Added incomplete ingredients alert
**Technical Highlights:**
- Levenshtein distance algorithm for fuzzy name matching
- Pattern-based category suggestions (supports 100+ ingredient patterns)
- Metadata tracking (needs_review, created_context)
- Real-time validation and error handling
- Responsive UI with animations
- Consistent with existing design system
All features built and tested successfully.
Build time: 21.29s
2025-11-06 15:39:30 +00:00
|
|
|
import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal';
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
interface UploadSalesDataStepProps {
|
|
|
|
|
onNext: () => void;
|
|
|
|
|
onPrevious: () => void;
|
|
|
|
|
onComplete: (data?: any) => void;
|
|
|
|
|
isFirstStep: boolean;
|
|
|
|
|
isLastStep: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProgressState {
|
|
|
|
|
stage: string;
|
|
|
|
|
progress: number;
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
interface InventoryItemForm {
|
2025-11-06 15:01:24 +00:00
|
|
|
id: string; // Unique ID for UI tracking
|
2025-11-06 15:09:23 +00:00
|
|
|
name: string;
|
2025-09-08 21:44:04 +02:00
|
|
|
product_type: string;
|
2025-09-08 17:19:00 +02:00
|
|
|
category: string;
|
|
|
|
|
unit_of_measure: string;
|
2025-11-06 15:09:23 +00:00
|
|
|
stock_quantity: number;
|
|
|
|
|
cost_per_unit: number;
|
2025-11-06 15:01:24 +00:00
|
|
|
estimated_shelf_life_days: number;
|
2025-09-08 17:19:00 +02:00
|
|
|
requires_refrigeration: boolean;
|
|
|
|
|
requires_freezing: boolean;
|
|
|
|
|
is_seasonal: boolean;
|
2025-11-06 15:09:23 +00:00
|
|
|
low_stock_threshold: number;
|
|
|
|
|
reorder_point: number;
|
|
|
|
|
notes: string;
|
|
|
|
|
// AI suggestion metadata (if from AI)
|
|
|
|
|
isSuggested: boolean;
|
|
|
|
|
confidence_score?: number;
|
2025-09-08 21:44:04 +02:00
|
|
|
sales_data?: {
|
|
|
|
|
total_quantity: number;
|
|
|
|
|
average_daily_sales: number;
|
|
|
|
|
};
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
|
|
|
|
|
onComplete,
|
|
|
|
|
isFirstStep
|
|
|
|
|
}) => {
|
2025-10-19 19:22:37 +02:00
|
|
|
const { t } = useTranslation();
|
2025-09-08 17:19:00 +02:00
|
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
|
|
|
const [isValidating, setIsValidating] = useState(false);
|
|
|
|
|
const [validationResult, setValidationResult] = useState<ImportValidationResponse | null>(null);
|
2025-11-06 15:09:23 +00:00
|
|
|
const [inventoryItems, setInventoryItems] = useState<InventoryItemForm[]>([]);
|
2025-09-08 17:19:00 +02:00
|
|
|
const [showInventoryStep, setShowInventoryStep] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string>('');
|
|
|
|
|
const [progressState, setProgressState] = useState<ProgressState | null>(null);
|
2025-10-19 19:22:37 +02:00
|
|
|
const [showGuide, setShowGuide] = useState(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// Form state for adding/editing
|
|
|
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
Implement 5 UX enhancements for ingredient management
This commit implements the requested enhancements for the ingredient
quick-add system and batch management:
**1. Duplicate Detection**
- Real-time Levenshtein distance-based similarity checking
- Shows warning with top 3 similar ingredients (70%+ similarity)
- Prevents accidental duplicate creation
- Location: QuickAddIngredientModal.tsx
**2. Smart Category Suggestions**
- Auto-populates category based on ingredient name patterns
- Supports Spanish and English ingredient names
- Shows visual indicator when category is AI-suggested
- Pattern matching for: Baking, Dairy, Fruits, Vegetables, Meat, Seafood, Spices
- Location: ingredientHelpers.ts
**3. Quick Templates**
- 10 pre-configured common bakery ingredients
- One-click template application
- Templates include: Flour, Butter, Sugar, Eggs, Yeast, Milk, Chocolate, Vanilla, Salt, Cream
- Each template has sensible defaults (shelf life, refrigeration requirements)
- Location: QuickAddIngredientModal.tsx
**4. Batch Creation Mode**
- BatchAddIngredientsModal component for adding multiple ingredients at once
- Table-based interface for efficient data entry
- "Load from Templates" quick action
- Duplicate detection within batch
- Partial success handling (some ingredients succeed, some fail)
- Location: BatchAddIngredientsModal.tsx
- Integration: UploadSalesDataStep.tsx (2 buttons: "Add One" / "Add Multiple")
**5. Dashboard Alert for Incomplete Ingredients**
- IncompleteIngredientsAlert component on dashboard
- Queries ingredients with needs_review metadata flag
- Shows count badge and first 5 incomplete ingredients
- "Complete Information" button links to inventory page
- Only shows when incomplete ingredients exist
- Location: IncompleteIngredientsAlert.tsx
- Integration: DashboardPage.tsx
**New Files Created:**
- ingredientHelpers.ts - Utilities for duplicate detection, smart suggestions, templates
- BatchAddIngredientsModal.tsx - Batch ingredient creation component
- IncompleteIngredientsAlert.tsx - Dashboard alert component
**Files Modified:**
- QuickAddIngredientModal.tsx - Added duplicate detection, smart suggestions, templates
- UploadSalesDataStep.tsx - Integrated batch creation modal
- DashboardPage.tsx - Added incomplete ingredients alert
**Technical Highlights:**
- Levenshtein distance algorithm for fuzzy name matching
- Pattern-based category suggestions (supports 100+ ingredient patterns)
- Metadata tracking (needs_review, created_context)
- Real-time validation and error handling
- Responsive UI with animations
- Consistent with existing design system
All features built and tested successfully.
Build time: 21.29s
2025-11-06 15:39:30 +00:00
|
|
|
const [showBatchModal, setShowBatchModal] = useState(false);
|
2025-11-06 15:09:23 +00:00
|
|
|
const [formData, setFormData] = useState<InventoryItemForm>({
|
|
|
|
|
id: '',
|
|
|
|
|
name: '',
|
|
|
|
|
product_type: 'ingredient',
|
|
|
|
|
category: '',
|
|
|
|
|
unit_of_measure: 'kg',
|
|
|
|
|
stock_quantity: 0,
|
|
|
|
|
cost_per_unit: 0,
|
|
|
|
|
estimated_shelf_life_days: 30,
|
|
|
|
|
requires_refrigeration: false,
|
|
|
|
|
requires_freezing: false,
|
|
|
|
|
is_seasonal: false,
|
|
|
|
|
low_stock_threshold: 0,
|
|
|
|
|
reorder_point: 0,
|
|
|
|
|
notes: '',
|
|
|
|
|
isSuggested: false,
|
|
|
|
|
});
|
|
|
|
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
|
|
|
|
|
2025-09-08 17:19:00 +02:00
|
|
|
const currentTenant = useCurrentTenant();
|
2025-09-08 22:28:26 +02:00
|
|
|
const { user } = useAuth();
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
const tenantId = currentTenant?.id || '';
|
|
|
|
|
|
|
|
|
|
// API hooks
|
2025-10-06 15:27:01 +02:00
|
|
|
const validateFileMutation = useValidateImportFile();
|
2025-09-08 17:19:00 +02:00
|
|
|
const createIngredient = useCreateIngredient();
|
2025-10-06 15:27:01 +02:00
|
|
|
const importMutation = useImportSalesData();
|
|
|
|
|
const classifyBatchMutation = useClassifyBatch();
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
const addStockMutation = useAddStock();
|
|
|
|
|
|
|
|
|
|
// Fetch suppliers for stock entry
|
|
|
|
|
const { data: suppliersData } = useSuppliers(tenantId, { limit: 100 }, { enabled: !!tenantId });
|
|
|
|
|
const suppliers = (suppliersData || []).filter(s => s.status === 'active');
|
|
|
|
|
|
|
|
|
|
// Stock lots state
|
|
|
|
|
const [addingStockForId, setAddingStockForId] = useState<string | null>(null);
|
|
|
|
|
const [stockFormData, setStockFormData] = useState({
|
|
|
|
|
current_quantity: '',
|
|
|
|
|
expiration_date: '',
|
|
|
|
|
supplier_id: '',
|
|
|
|
|
batch_number: '',
|
|
|
|
|
});
|
|
|
|
|
const [stockErrors, setStockErrors] = useState<Record<string, string>>({});
|
|
|
|
|
const [ingredientStocks, setIngredientStocks] = useState<Record<string, StockResponse[]>>({});
|
2025-09-08 17:19:00 +02:00
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
2025-09-08 17:19:00 +02:00
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
if (file) {
|
|
|
|
|
setSelectedFile(file);
|
|
|
|
|
setValidationResult(null);
|
|
|
|
|
setError('');
|
2025-09-08 22:28:26 +02:00
|
|
|
await handleAutoValidateAndClassify(file);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
2025-09-08 17:19:00 +02:00
|
|
|
event.preventDefault();
|
|
|
|
|
const file = event.dataTransfer.files[0];
|
|
|
|
|
if (file) {
|
|
|
|
|
setSelectedFile(file);
|
|
|
|
|
setValidationResult(null);
|
|
|
|
|
setError('');
|
2025-09-08 22:28:26 +02:00
|
|
|
await handleAutoValidateAndClassify(file);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
const handleAutoValidateAndClassify = async (file: File) => {
|
|
|
|
|
if (!currentTenant?.id) return;
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
setIsValidating(true);
|
|
|
|
|
setError('');
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'preparing', progress: 0, message: 'Preparando validación automática del archivo...' });
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
try {
|
2025-09-08 22:28:26 +02:00
|
|
|
// Step 1: Validate the file
|
2025-10-06 15:27:01 +02:00
|
|
|
const validationResult = await validateFileMutation.mutateAsync({
|
|
|
|
|
tenantId: currentTenant.id,
|
|
|
|
|
file
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (validationResult && validationResult.is_valid !== undefined) {
|
|
|
|
|
setValidationResult(validationResult);
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'analyzing', progress: 60, message: 'Validación exitosa. Generando sugerencias automáticamente...' });
|
2025-10-06 15:27:01 +02:00
|
|
|
await generateInventorySuggestionsAuto(validationResult);
|
2025-09-08 17:19:00 +02:00
|
|
|
} else {
|
2025-10-06 15:27:01 +02:00
|
|
|
setError('Respuesta de validación inválida del servidor');
|
2025-09-08 17:19:00 +02:00
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setError('Error validando archivo: ' + (error instanceof Error ? error.message : 'Error desconocido'));
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-19 19:22:37 +02:00
|
|
|
const generateInventorySuggestionsAuto = async (validationData: ImportValidationResponse) => {
|
2025-09-08 22:28:26 +02:00
|
|
|
if (!currentTenant?.id) {
|
2025-09-08 17:19:00 +02:00
|
|
|
setError('No hay datos de validación disponibles para generar sugerencias');
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
|
|
|
|
setProgressState(null);
|
2025-09-08 17:19:00 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'analyzing', progress: 65, message: 'Analizando productos de ventas...' });
|
|
|
|
|
|
|
|
|
|
const products = validationData.product_list?.map((productName: string) => ({
|
2025-09-08 21:44:04 +02:00
|
|
|
product_name: productName
|
2025-09-08 17:19:00 +02:00
|
|
|
})) || [];
|
|
|
|
|
|
|
|
|
|
if (products.length === 0) {
|
|
|
|
|
setError('No se encontraron productos en los datos de ventas');
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'classifying', progress: 75, message: 'Clasificando productos con IA...' });
|
2025-09-08 17:19:00 +02:00
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
const classificationResponse = await classifyBatchMutation.mutateAsync({
|
2025-09-08 17:19:00 +02:00
|
|
|
tenantId: currentTenant.id,
|
2025-10-06 15:27:01 +02:00
|
|
|
products
|
2025-09-08 17:19:00 +02:00
|
|
|
});
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'preparing', progress: 90, message: 'Preparando sugerencias de inventario...' });
|
2025-09-08 17:19:00 +02:00
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// Convert AI suggestions to inventory items (NOT created yet, just added to list)
|
|
|
|
|
const items: InventoryItemForm[] = classificationResponse.suggestions.map((suggestion: ProductSuggestionResponse, index: number) => {
|
2025-09-08 17:19:00 +02:00
|
|
|
const defaultStock = Math.max(
|
2025-11-06 15:09:23 +00:00
|
|
|
Math.ceil((suggestion.sales_data?.average_daily_sales || 1) * 7),
|
2025-09-08 17:19:00 +02:00
|
|
|
1
|
|
|
|
|
);
|
2025-11-06 15:01:24 +00:00
|
|
|
const estimatedCost = suggestion.category === 'Dairy' ? 5.0 :
|
2025-11-06 15:09:23 +00:00
|
|
|
suggestion.category === 'Baking Ingredients' ? 2.0 : 3.0;
|
2025-11-06 15:01:24 +00:00
|
|
|
const minimumStock = Math.max(1, Math.ceil(defaultStock * 0.2));
|
2025-11-06 15:09:23 +00:00
|
|
|
const reorderPoint = Math.max(minimumStock + 2, Math.ceil(defaultStock * 0.3), minimumStock + 1);
|
2025-11-06 15:01:24 +00:00
|
|
|
|
2025-09-08 17:19:00 +02:00
|
|
|
return {
|
2025-11-06 15:01:24 +00:00
|
|
|
id: `ai-${index}-${Date.now()}`,
|
2025-11-06 15:09:23 +00:00
|
|
|
name: suggestion.suggested_name,
|
2025-09-08 21:44:04 +02:00
|
|
|
product_type: suggestion.product_type,
|
2025-09-08 17:19:00 +02:00
|
|
|
category: suggestion.category,
|
|
|
|
|
unit_of_measure: suggestion.unit_of_measure,
|
2025-11-06 15:09:23 +00:00
|
|
|
stock_quantity: defaultStock,
|
|
|
|
|
cost_per_unit: estimatedCost,
|
2025-11-06 15:01:24 +00:00
|
|
|
estimated_shelf_life_days: suggestion.estimated_shelf_life_days || 30,
|
2025-09-08 17:19:00 +02:00
|
|
|
requires_refrigeration: suggestion.requires_refrigeration,
|
|
|
|
|
requires_freezing: suggestion.requires_freezing,
|
|
|
|
|
is_seasonal: suggestion.is_seasonal,
|
2025-11-06 15:01:24 +00:00
|
|
|
low_stock_threshold: minimumStock,
|
2025-11-06 15:09:23 +00:00
|
|
|
reorder_point: reorderPoint,
|
|
|
|
|
notes: `AI generado - Confianza: ${Math.round(suggestion.confidence_score * 100)}%`,
|
|
|
|
|
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,
|
2025-09-08 17:19:00 +02:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setInventoryItems(items);
|
|
|
|
|
setShowInventoryStep(true);
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error generating inventory suggestions:', err);
|
|
|
|
|
setError('Error al generar sugerencias de inventario. Por favor, inténtalo de nuevo.');
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// Form validation
|
|
|
|
|
const validateForm = (): boolean => {
|
|
|
|
|
const newErrors: Record<string, string> = {};
|
2025-09-08 22:28:26 +02:00
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
if (!formData.name.trim()) {
|
|
|
|
|
newErrors.name = 'El nombre es requerido';
|
|
|
|
|
}
|
|
|
|
|
if (!formData.category.trim()) {
|
|
|
|
|
newErrors.category = 'La categoría es requerida';
|
|
|
|
|
}
|
|
|
|
|
if (formData.stock_quantity < 0) {
|
|
|
|
|
newErrors.stock_quantity = 'El stock debe ser 0 o mayor';
|
|
|
|
|
}
|
|
|
|
|
if (formData.cost_per_unit < 0) {
|
|
|
|
|
newErrors.cost_per_unit = 'El costo debe ser 0 o mayor';
|
|
|
|
|
}
|
|
|
|
|
if (formData.estimated_shelf_life_days <= 0) {
|
|
|
|
|
newErrors.estimated_shelf_life_days = 'Los días de caducidad deben ser mayores a 0';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setFormErrors(newErrors);
|
|
|
|
|
return Object.keys(newErrors).length === 0;
|
2025-09-08 17:19:00 +02:00
|
|
|
};
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// Add or update item in list
|
|
|
|
|
const handleSubmitForm = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!validateForm()) return;
|
|
|
|
|
|
|
|
|
|
if (editingId) {
|
|
|
|
|
// Update existing item
|
|
|
|
|
setInventoryItems(items =>
|
|
|
|
|
items.map(item =>
|
|
|
|
|
item.id === editingId ? { ...formData, id: editingId } : item
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
} else {
|
|
|
|
|
// Add new item
|
|
|
|
|
const newItem: InventoryItemForm = {
|
|
|
|
|
...formData,
|
|
|
|
|
id: `manual-${Date.now()}`,
|
|
|
|
|
isSuggested: false,
|
|
|
|
|
};
|
|
|
|
|
setInventoryItems(items => [...items, newItem]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetForm();
|
2025-09-08 17:19:00 +02:00
|
|
|
};
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
const resetForm = () => {
|
|
|
|
|
setFormData({
|
|
|
|
|
id: '',
|
|
|
|
|
name: '',
|
|
|
|
|
product_type: 'ingredient',
|
|
|
|
|
category: '',
|
|
|
|
|
unit_of_measure: 'kg',
|
|
|
|
|
stock_quantity: 0,
|
|
|
|
|
cost_per_unit: 0,
|
|
|
|
|
estimated_shelf_life_days: 30,
|
|
|
|
|
requires_refrigeration: false,
|
|
|
|
|
requires_freezing: false,
|
|
|
|
|
is_seasonal: false,
|
|
|
|
|
low_stock_threshold: 0,
|
|
|
|
|
reorder_point: 0,
|
|
|
|
|
notes: '',
|
|
|
|
|
isSuggested: false,
|
|
|
|
|
});
|
|
|
|
|
setFormErrors({});
|
|
|
|
|
setIsAdding(false);
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEdit = (item: InventoryItemForm) => {
|
|
|
|
|
setFormData(item);
|
|
|
|
|
setEditingId(item.id);
|
|
|
|
|
setIsAdding(true);
|
2025-09-08 17:19:00 +02:00
|
|
|
};
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
const handleDelete = (itemId: string) => {
|
|
|
|
|
if (!window.confirm('¿Estás seguro de que quieres eliminar este ingrediente de la lista?')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setInventoryItems(items => items.filter(item => item.id !== itemId));
|
|
|
|
|
};
|
2025-10-15 21:09:42 +02:00
|
|
|
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
// Stock lot handlers
|
|
|
|
|
const handleAddStockClick = (ingredientId: string) => {
|
|
|
|
|
setAddingStockForId(ingredientId);
|
|
|
|
|
setStockFormData({
|
|
|
|
|
current_quantity: '',
|
|
|
|
|
expiration_date: '',
|
|
|
|
|
supplier_id: suppliers.length === 1 ? suppliers[0].id : '',
|
|
|
|
|
batch_number: '',
|
|
|
|
|
});
|
|
|
|
|
setStockErrors({});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCancelStock = () => {
|
|
|
|
|
setAddingStockForId(null);
|
|
|
|
|
setStockFormData({
|
|
|
|
|
current_quantity: '',
|
|
|
|
|
expiration_date: '',
|
|
|
|
|
supplier_id: '',
|
|
|
|
|
batch_number: '',
|
|
|
|
|
});
|
|
|
|
|
setStockErrors({});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validateStockForm = (): boolean => {
|
|
|
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
|
|
|
|
|
|
if (!stockFormData.current_quantity || Number(stockFormData.current_quantity) <= 0) {
|
|
|
|
|
newErrors.current_quantity = 'La cantidad debe ser mayor que cero';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (stockFormData.expiration_date) {
|
|
|
|
|
const expDate = new Date(stockFormData.expiration_date);
|
|
|
|
|
const today = new Date();
|
|
|
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
|
|
|
|
|
|
if (expDate < today) {
|
|
|
|
|
newErrors.expiration_date = 'La fecha de caducidad está en el pasado';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const threeDaysFromNow = new Date(today);
|
|
|
|
|
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
|
|
|
|
|
if (expDate < threeDaysFromNow) {
|
|
|
|
|
newErrors.expiration_warning = '⚠️ Este ingrediente caduca muy pronto!';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStockErrors(newErrors);
|
|
|
|
|
return Object.keys(newErrors).filter(k => k !== 'expiration_warning').length === 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSaveStockLot = (addAnother: boolean = false) => {
|
|
|
|
|
if (!addingStockForId || !validateStockForm()) return;
|
|
|
|
|
|
|
|
|
|
// Create a temporary stock lot entry (will be saved when ingredients are created)
|
|
|
|
|
const newLot: StockResponse = {
|
|
|
|
|
id: `temp-${Date.now()}`,
|
|
|
|
|
tenant_id: tenantId,
|
|
|
|
|
ingredient_id: addingStockForId,
|
|
|
|
|
current_quantity: Number(stockFormData.current_quantity),
|
|
|
|
|
expiration_date: stockFormData.expiration_date || undefined,
|
|
|
|
|
supplier_id: stockFormData.supplier_id || undefined,
|
|
|
|
|
batch_number: stockFormData.batch_number || undefined,
|
|
|
|
|
production_stage: ProductionStage.RAW_INGREDIENT,
|
|
|
|
|
quality_status: 'good',
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
updated_at: new Date().toISOString(),
|
|
|
|
|
} as StockResponse;
|
|
|
|
|
|
|
|
|
|
// Add to local state
|
|
|
|
|
setIngredientStocks(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[addingStockForId]: [...(prev[addingStockForId] || []), newLot],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (addAnother) {
|
|
|
|
|
// Reset form for adding another lot
|
|
|
|
|
setStockFormData({
|
|
|
|
|
current_quantity: '',
|
|
|
|
|
expiration_date: '',
|
|
|
|
|
supplier_id: stockFormData.supplier_id, // Keep supplier selected
|
|
|
|
|
batch_number: '',
|
|
|
|
|
});
|
|
|
|
|
setStockErrors({});
|
|
|
|
|
} else {
|
|
|
|
|
handleCancelStock();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteStockLot = (ingredientId: string, stockId: string) => {
|
|
|
|
|
setIngredientStocks(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[ingredientId]: (prev[ingredientId] || []).filter(s => s.id !== stockId),
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// Create all inventory items when Next is clicked
|
|
|
|
|
const handleNext = async () => {
|
|
|
|
|
if (inventoryItems.length === 0) {
|
|
|
|
|
setError('Por favor agrega al menos un ingrediente antes de continuar');
|
2025-09-08 17:19:00 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentTenant?.id) {
|
|
|
|
|
setError('No se encontró información del tenant');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
setProgressState({
|
|
|
|
|
stage: 'creating_inventory',
|
|
|
|
|
progress: 10,
|
|
|
|
|
message: `Creando ${inventoryItems.length} ingredientes...`
|
|
|
|
|
});
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
try {
|
2025-11-06 15:09:23 +00:00
|
|
|
// Create all ingredients in parallel
|
|
|
|
|
const creationPromises = inventoryItems.map(item => {
|
2025-09-08 17:19:00 +02:00
|
|
|
const ingredientData = {
|
2025-11-06 15:09:23 +00:00
|
|
|
name: item.name,
|
2025-11-05 13:34:56 +01:00
|
|
|
product_type: item.product_type,
|
2025-09-08 17:19:00 +02:00
|
|
|
category: item.category,
|
|
|
|
|
unit_of_measure: item.unit_of_measure,
|
2025-11-06 15:09:23 +00:00
|
|
|
low_stock_threshold: item.low_stock_threshold,
|
2025-09-08 21:44:04 +02:00
|
|
|
max_stock_level: item.stock_quantity * 2,
|
2025-11-06 15:09:23 +00:00
|
|
|
reorder_point: item.reorder_point,
|
|
|
|
|
shelf_life_days: item.estimated_shelf_life_days,
|
2025-09-08 17:19:00 +02:00
|
|
|
requires_refrigeration: item.requires_refrigeration,
|
|
|
|
|
requires_freezing: item.requires_freezing,
|
|
|
|
|
is_seasonal: item.is_seasonal,
|
2025-09-08 21:44:04 +02:00
|
|
|
average_cost: item.cost_per_unit,
|
2025-11-06 15:09:23 +00:00
|
|
|
notes: item.notes || undefined,
|
2025-09-08 17:19:00 +02:00
|
|
|
};
|
|
|
|
|
|
2025-10-15 21:09:42 +02:00
|
|
|
return createIngredient.mutateAsync({
|
2025-09-08 17:19:00 +02:00
|
|
|
tenantId: currentTenant.id,
|
|
|
|
|
ingredientData
|
2025-10-15 21:09:42 +02:00
|
|
|
}).then(created => ({
|
2025-09-08 17:19:00 +02:00
|
|
|
...created,
|
|
|
|
|
initialStock: item.stock_quantity
|
2025-10-15 21:09:42 +02:00
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const results = await Promise.allSettled(creationPromises);
|
|
|
|
|
|
|
|
|
|
const createdIngredients = results
|
|
|
|
|
.filter(r => r.status === 'fulfilled')
|
|
|
|
|
.map(r => (r as PromiseFulfilledResult<any>).value);
|
|
|
|
|
|
|
|
|
|
const failedCount = results.filter(r => r.status === 'rejected').length;
|
|
|
|
|
|
|
|
|
|
if (failedCount > 0) {
|
2025-11-06 15:09:23 +00:00
|
|
|
console.warn(`${failedCount} ingredientes fallaron al crear de ${inventoryItems.length}`);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
console.log(`Creados exitosamente ${createdIngredients.length} ingredientes`);
|
2025-10-15 21:09:42 +02:00
|
|
|
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
// Create stock lots for ingredients
|
|
|
|
|
setProgressState({
|
|
|
|
|
stage: 'creating_stock',
|
|
|
|
|
progress: 40,
|
|
|
|
|
message: 'Creando lotes de stock...'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const stockCreationPromises: Promise<any>[] = [];
|
|
|
|
|
|
|
|
|
|
createdIngredients.forEach((ingredient) => {
|
|
|
|
|
// Find the original UI item to get its temporary ID
|
|
|
|
|
const originalItem = inventoryItems.find(item => item.name === ingredient.name);
|
|
|
|
|
if (originalItem) {
|
|
|
|
|
const lots = ingredientStocks[originalItem.id] || [];
|
|
|
|
|
|
|
|
|
|
// Create stock lots for this ingredient
|
|
|
|
|
lots.forEach((lot) => {
|
|
|
|
|
const stockData: StockCreate = {
|
|
|
|
|
ingredient_id: ingredient.id,
|
|
|
|
|
current_quantity: lot.current_quantity,
|
|
|
|
|
production_stage: ProductionStage.RAW_INGREDIENT,
|
|
|
|
|
quality_status: 'good',
|
|
|
|
|
expiration_date: lot.expiration_date,
|
|
|
|
|
supplier_id: lot.supplier_id,
|
|
|
|
|
batch_number: lot.batch_number,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
stockCreationPromises.push(
|
|
|
|
|
addStockMutation.mutateAsync({
|
|
|
|
|
tenantId: currentTenant.id,
|
|
|
|
|
stockData
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
console.error(`Error creando lote de stock para ${ingredient.name}:`, err);
|
|
|
|
|
return null;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (stockCreationPromises.length > 0) {
|
|
|
|
|
await Promise.allSettled(stockCreationPromises);
|
|
|
|
|
console.log(`Creados exitosamente ${stockCreationPromises.length} lotes de stock`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// Import sales data if available
|
2025-10-15 21:09:42 +02:00
|
|
|
setProgressState({
|
|
|
|
|
stage: 'importing_sales',
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
progress: 60,
|
2025-10-15 21:09:42 +02:00
|
|
|
message: 'Importando datos de ventas...'
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-08 17:19:00 +02:00
|
|
|
let salesImportResult = null;
|
|
|
|
|
try {
|
|
|
|
|
if (selectedFile) {
|
2025-10-06 15:27:01 +02:00
|
|
|
const result = await importMutation.mutateAsync({
|
|
|
|
|
tenantId: currentTenant.id,
|
|
|
|
|
file: selectedFile
|
|
|
|
|
});
|
2025-09-08 17:19:00 +02:00
|
|
|
salesImportResult = result;
|
|
|
|
|
if (result.success) {
|
2025-11-06 15:09:23 +00:00
|
|
|
console.log('Datos de ventas importados exitosamente');
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (importError) {
|
2025-11-06 15:09:23 +00:00
|
|
|
console.error('Error importando datos de ventas:', importError);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setProgressState(null);
|
2025-11-06 15:09:23 +00:00
|
|
|
|
|
|
|
|
// Complete step
|
2025-09-08 17:19:00 +02:00
|
|
|
onComplete({
|
|
|
|
|
createdIngredients,
|
2025-10-15 21:09:42 +02:00
|
|
|
totalItems: createdIngredients.length,
|
2025-09-08 17:19:00 +02:00
|
|
|
validationResult,
|
|
|
|
|
file: selectedFile,
|
2025-09-08 22:28:26 +02:00
|
|
|
salesImportResult,
|
2025-10-15 21:09:42 +02:00
|
|
|
inventoryConfigured: true,
|
|
|
|
|
shouldAutoCompleteSuppliers: true,
|
|
|
|
|
userId: user?.id
|
2025-09-08 17:19:00 +02:00
|
|
|
});
|
|
|
|
|
} catch (err) {
|
2025-11-06 15:09:23 +00:00
|
|
|
console.error('Error creando ingredientes:', err);
|
|
|
|
|
setError('Error al crear ingredientes. Por favor, inténtalo de nuevo.');
|
2025-09-08 17:19:00 +02:00
|
|
|
setProgressState(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
|
|
|
if (bytes === 0) return '0 Bytes';
|
|
|
|
|
const k = 1024;
|
|
|
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
const categoryOptions = [
|
|
|
|
|
'Baking Ingredients',
|
|
|
|
|
'Dairy',
|
|
|
|
|
'Fruits',
|
|
|
|
|
'Vegetables',
|
|
|
|
|
'Meat',
|
|
|
|
|
'Seafood',
|
|
|
|
|
'Spices',
|
|
|
|
|
'Other'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const unitOptions = ['kg', 'g', 'L', 'ml', 'units', 'dozen'];
|
2025-09-08 17:19:00 +02:00
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// INVENTORY LIST VIEW (after AI suggestions loaded)
|
2025-09-08 17:19:00 +02:00
|
|
|
if (showInventoryStep) {
|
2025-11-06 15:09:23 +00:00
|
|
|
const canContinue = inventoryItems.length >= 1;
|
|
|
|
|
|
2025-09-08 17:19:00 +02:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
2025-11-06 14:36:10 +00:00
|
|
|
{/* 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">
|
|
|
|
|
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
2025-11-06 15:09:23 +00:00
|
|
|
{t('onboarding:ai_suggestions.why_title', 'Configurar Inventario')}
|
2025-11-06 14:36:10 +00:00
|
|
|
</h3>
|
|
|
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
2025-11-06 15:09:23 +00:00
|
|
|
Revisa y edita los ingredientes sugeridos por IA. Puedes agregar más ingredientes manualmente. Cuando hagas clic en "Siguiente", se crearán todos los ingredientes.
|
2025-09-08 17:19:00 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
{/* Inventory Items List */}
|
2025-11-06 14:36:10 +00:00
|
|
|
<div>
|
|
|
|
|
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-3">
|
2025-11-06 15:09:23 +00:00
|
|
|
Ingredientes ({inventoryItems.length})
|
2025-11-06 14:36:10 +00:00
|
|
|
</h4>
|
2025-11-06 15:09:23 +00:00
|
|
|
|
|
|
|
|
{inventoryItems.length > 0 ? (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{inventoryItems.map((item) => (
|
2025-11-06 21:45:38 +00:00
|
|
|
<div key={item.id}>
|
|
|
|
|
{/* Show ingredient card only if NOT editing this item */}
|
|
|
|
|
{editingId !== item.id && (
|
|
|
|
|
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
|
|
|
<div className="flex items-start justify-between">
|
2025-11-06 15:09:23 +00:00
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<h5 className="font-medium text-[var(--text-primary)]">
|
|
|
|
|
{item.name}
|
|
|
|
|
</h5>
|
|
|
|
|
{item.isSuggested && item.confidence_score && (
|
|
|
|
|
<span className="text-xs bg-[var(--color-info)]/10 text-[var(--color-info)] px-2 py-0.5 rounded-full">
|
|
|
|
|
IA {Math.round(item.confidence_score * 100)}%
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
|
|
|
|
{item.category} • {item.unit_of_measure}
|
|
|
|
|
</p>
|
|
|
|
|
<div className="mt-2 flex items-center gap-4 text-xs text-[var(--text-secondary)]">
|
|
|
|
|
<span>Stock: {item.stock_quantity} {item.unit_of_measure}</span>
|
|
|
|
|
<span>Costo: €{item.cost_per_unit.toFixed(2)}/{item.unit_of_measure}</span>
|
|
|
|
|
<span>Caducidad: {item.estimated_shelf_life_days} días</span>
|
|
|
|
|
</div>
|
|
|
|
|
{item.sales_data && (
|
|
|
|
|
<div className="mt-2 text-xs text-[var(--text-tertiary)]">
|
|
|
|
|
📊 Ventas: {item.sales_data.average_daily_sales.toFixed(1)}/día
|
|
|
|
|
</div>
|
2025-11-06 14:36:10 +00:00
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-06 15:09:23 +00:00
|
|
|
<div className="flex items-center gap-2 ml-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleEdit(item)}
|
|
|
|
|
className="p-2 text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
|
|
|
|
title="Editar"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleDelete(item.id)}
|
|
|
|
|
className="p-2 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors"
|
|
|
|
|
title="Eliminar"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-11-06 14:36:10 +00:00
|
|
|
</div>
|
Remove manual path and add inventory lots UI to AI-assisted onboarding
## Architectural Changes
**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext
Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64
**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:
**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists
**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
- handleAddStockClick - Opens stock form
- handleCancelStock - Closes and resets form
- validateStockForm - Validates quantity and expiration
- handleSaveStockLot - Saves to local state, supports "Add Another Lot"
- handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)
**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one
**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start
Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830
**Build Status:** ✓ Successful in 20.78s
2025-11-06 21:40:39 +00:00
|
|
|
|
|
|
|
|
{/* Stock Lots Section */}
|
|
|
|
|
{(() => {
|
|
|
|
|
const lots = ingredientStocks[item.id] || [];
|
|
|
|
|
const isAddingStock = addingStockForId === item.id;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="mt-4 pt-4 border-t border-[var(--border-secondary)]">
|
|
|
|
|
{lots.length > 0 && (
|
|
|
|
|
<div className="mb-3">
|
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
|
|
|
<span className="text-xs font-medium text-[var(--text-secondary)]">
|
|
|
|
|
Lotes agregados ({lots.length})
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{lots.map((lot) => (
|
|
|
|
|
<div
|
|
|
|
|
key={lot.id}
|
|
|
|
|
className="flex items-center justify-between p-2 bg-[var(--bg-primary)] rounded text-xs"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<span className="font-medium">{lot.current_quantity} {item.unit_of_measure}</span>
|
|
|
|
|
{lot.expiration_date && (
|
|
|
|
|
<span className="text-[var(--text-tertiary)]">
|
|
|
|
|
📅 Caduca: {new Date(lot.expiration_date).toLocaleDateString('es-ES')}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{lot.batch_number && (
|
|
|
|
|
<span className="text-[var(--text-tertiary)]">
|
|
|
|
|
Lote: {lot.batch_number}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleDeleteStockLot(item.id, lot.id)}
|
|
|
|
|
className="p-1 text-[var(--text-secondary)] hover:text-[var(--color-error)] rounded"
|
|
|
|
|
title="Eliminar lote"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isAddingStock ? (
|
|
|
|
|
<div className="p-3 bg-[var(--color-primary)]/5 border-2 border-[var(--color-primary)] rounded-lg">
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Cantidad * <span className="text-[var(--text-tertiary)]">({item.unit_of_measure})</span>
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
value={stockFormData.current_quantity}
|
|
|
|
|
onChange={(e) => setStockFormData(prev => ({ ...prev, current_quantity: e.target.value }))}
|
|
|
|
|
className="w-full px-2 py-1 text-sm border rounded"
|
|
|
|
|
placeholder="0"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.01"
|
|
|
|
|
/>
|
|
|
|
|
{stockErrors.current_quantity && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{stockErrors.current_quantity}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Fecha de caducidad
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
value={stockFormData.expiration_date}
|
|
|
|
|
onChange={(e) => setStockFormData(prev => ({ ...prev, expiration_date: e.target.value }))}
|
|
|
|
|
className="w-full px-2 py-1 text-sm border rounded"
|
|
|
|
|
/>
|
|
|
|
|
{stockErrors.expiration_date && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{stockErrors.expiration_date}</p>
|
|
|
|
|
)}
|
|
|
|
|
{stockErrors.expiration_warning && (
|
|
|
|
|
<p className="text-xs text-[var(--color-warning)] mt-1">{stockErrors.expiration_warning}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Proveedor
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={stockFormData.supplier_id}
|
|
|
|
|
onChange={(e) => setStockFormData(prev => ({ ...prev, supplier_id: e.target.value }))}
|
|
|
|
|
className="w-full px-2 py-1 text-sm border rounded"
|
|
|
|
|
>
|
|
|
|
|
<option value="">Seleccionar...</option>
|
|
|
|
|
{suppliers.map(s => (
|
|
|
|
|
<option key={s.id} value={s.id}>{s.company_name}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Número de lote
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={stockFormData.batch_number}
|
|
|
|
|
onChange={(e) => setStockFormData(prev => ({ ...prev, batch_number: e.target.value }))}
|
|
|
|
|
className="w-full px-2 py-1 text-sm border rounded"
|
|
|
|
|
placeholder="Opcional"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-[var(--text-tertiary)] italic">
|
|
|
|
|
💡 Los lotes con fecha de caducidad se gestionarán automáticamente con FIFO
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleSaveStockLot(true)}
|
|
|
|
|
className="px-3 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border rounded transition-colors"
|
|
|
|
|
>
|
|
|
|
|
+ Agregar Otro Lote
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleSaveStockLot(false)}
|
|
|
|
|
className="px-3 py-1 text-xs bg-[var(--color-primary)] text-white rounded hover:opacity-90 transition-opacity"
|
|
|
|
|
>
|
|
|
|
|
Guardar
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleCancelStock}
|
|
|
|
|
className="px-3 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Cancelar
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleAddStockClick(item.id)}
|
|
|
|
|
className="w-full px-3 py-2 text-xs border-2 border-dashed border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors text-[var(--text-secondary)] hover:text-[var(--color-primary)] font-medium"
|
|
|
|
|
>
|
|
|
|
|
{lots.length === 0 ? '+ Agregar Stock Inicial (Opcional)' : '+ Agregar Otro Lote'}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
2025-11-06 21:45:38 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Inline Edit Form - show only when editing this specific ingredient */}
|
|
|
|
|
{editingId === item.id && (
|
|
|
|
|
<div className="border-2 border-[var(--color-primary)] rounded-lg p-4 bg-gradient-to-br from-[var(--color-primary)]/5 to-transparent">
|
|
|
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-4">
|
|
|
|
|
Editar Ingrediente
|
|
|
|
|
</h4>
|
|
|
|
|
<form onSubmit={handleSubmitForm} className="space-y-4">
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Nombre *
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={formData.name}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
placeholder="Ej: Harina de trigo"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.name && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.name}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Categoría *
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={formData.category}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
>
|
|
|
|
|
<option value="">Seleccionar...</option>
|
|
|
|
|
{categoryOptions.map(cat => (
|
|
|
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
{formErrors.category && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.category}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Unidad de Medida
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={formData.unit_of_measure}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, unit_of_measure: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
>
|
|
|
|
|
{unitOptions.map(unit => (
|
|
|
|
|
<option key={unit} value={unit}>{unit}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Stock Inicial
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.01"
|
|
|
|
|
value={formData.stock_quantity}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, stock_quantity: parseFloat(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.stock_quantity && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.stock_quantity}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Costo por Unidad (€)
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.01"
|
|
|
|
|
value={formData.cost_per_unit}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, cost_per_unit: parseFloat(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.cost_per_unit && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.cost_per_unit}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Días de Caducidad
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
value={formData.estimated_shelf_life_days}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, estimated_shelf_life_days: parseInt(e.target.value) || 30 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.estimated_shelf_life_days && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.estimated_shelf_life_days}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Stock Mínimo
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={formData.low_stock_threshold}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, low_stock_threshold: parseInt(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Punto de Reorden
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={formData.reorder_point}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, reorder_point: parseInt(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={formData.requires_refrigeration}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, requires_refrigeration: e.target.checked })}
|
|
|
|
|
className="rounded"
|
|
|
|
|
/>
|
|
|
|
|
Requiere Refrigeración
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={formData.requires_freezing}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, requires_freezing: e.target.checked })}
|
|
|
|
|
className="rounded"
|
|
|
|
|
/>
|
|
|
|
|
Requiere Congelación
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={formData.is_seasonal}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
|
|
|
|
|
className="rounded"
|
|
|
|
|
/>
|
|
|
|
|
Estacional
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Notas (opcional)
|
|
|
|
|
</label>
|
|
|
|
|
<textarea
|
|
|
|
|
value={formData.notes}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
|
|
|
rows={2}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
placeholder="Información adicional..."
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors font-medium"
|
|
|
|
|
>
|
|
|
|
|
Guardar Cambios
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={resetForm}
|
|
|
|
|
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Cancelar
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-06 15:09:23 +00:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-center py-8 border border-dashed border-[var(--border-secondary)] rounded-lg">
|
|
|
|
|
<p className="text-[var(--text-tertiary)] text-sm">
|
|
|
|
|
No hay ingredientes en la lista todavía
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-09-08 17:19:00 +02:00
|
|
|
|
2025-11-06 21:45:38 +00:00
|
|
|
{/* Add Manually Form - only show when adding new (not editing existing) */}
|
|
|
|
|
{isAdding && !editingId ? (
|
2025-11-06 15:09:23 +00:00
|
|
|
<div className="border-2 border-[var(--color-primary)] rounded-lg p-4 bg-gradient-to-br from-[var(--color-primary)]/5 to-transparent">
|
|
|
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-4">
|
2025-11-06 21:45:38 +00:00
|
|
|
Agregar Ingrediente Manualmente
|
2025-11-06 15:09:23 +00:00
|
|
|
</h4>
|
|
|
|
|
<form onSubmit={handleSubmitForm} className="space-y-4">
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Nombre *
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={formData.name}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
placeholder="Ej: Harina de trigo"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.name && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.name}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-06 14:36:10 +00:00
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Categoría *
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={formData.category}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
>
|
|
|
|
|
<option value="">Seleccionar...</option>
|
|
|
|
|
{categoryOptions.map(cat => (
|
|
|
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
{formErrors.category && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.category}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Unidad de Medida
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={formData.unit_of_measure}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, unit_of_measure: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
>
|
|
|
|
|
{unitOptions.map(unit => (
|
|
|
|
|
<option key={unit} value={unit}>{unit}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Stock Inicial
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.01"
|
|
|
|
|
value={formData.stock_quantity}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, stock_quantity: parseFloat(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.stock_quantity && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.stock_quantity}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Costo por Unidad (€)
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.01"
|
|
|
|
|
value={formData.cost_per_unit}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, cost_per_unit: parseFloat(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.cost_per_unit && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.cost_per_unit}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Días de Caducidad
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
value={formData.estimated_shelf_life_days}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, estimated_shelf_life_days: parseInt(e.target.value) || 30 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
{formErrors.estimated_shelf_life_days && (
|
|
|
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.estimated_shelf_life_days}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Stock Mínimo
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={formData.low_stock_threshold}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, low_stock_threshold: parseInt(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Punto de Reorden
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={formData.reorder_point}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, reorder_point: parseInt(e.target.value) || 0 })}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
/>
|
2025-11-06 14:36:10 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={formData.requires_refrigeration}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, requires_refrigeration: e.target.checked })}
|
|
|
|
|
className="rounded"
|
|
|
|
|
/>
|
|
|
|
|
Requiere Refrigeración
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={formData.requires_freezing}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, requires_freezing: e.target.checked })}
|
|
|
|
|
className="rounded"
|
|
|
|
|
/>
|
|
|
|
|
Requiere Congelación
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={formData.is_seasonal}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
|
|
|
|
|
className="rounded"
|
|
|
|
|
/>
|
|
|
|
|
Estacional
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-06 14:36:10 +00:00
|
|
|
<div>
|
2025-11-06 15:09:23 +00:00
|
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
|
|
|
Notas (opcional)
|
|
|
|
|
</label>
|
|
|
|
|
<textarea
|
|
|
|
|
value={formData.notes}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
|
|
|
rows={2}
|
|
|
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
|
|
|
placeholder="Información adicional..."
|
|
|
|
|
/>
|
2025-11-06 14:36:10 +00:00
|
|
|
</div>
|
2025-09-08 17:19:00 +02:00
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
<div className="flex gap-2 pt-2">
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors font-medium"
|
2025-11-06 14:36:10 +00:00
|
|
|
>
|
2025-11-06 21:45:38 +00:00
|
|
|
Agregar a Lista
|
2025-11-06 15:09:23 +00:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={resetForm}
|
|
|
|
|
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Cancelar
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
2025-11-06 14:36:10 +00:00
|
|
|
</div>
|
2025-11-06 15:09:23 +00:00
|
|
|
) : (
|
Implement 5 UX enhancements for ingredient management
This commit implements the requested enhancements for the ingredient
quick-add system and batch management:
**1. Duplicate Detection**
- Real-time Levenshtein distance-based similarity checking
- Shows warning with top 3 similar ingredients (70%+ similarity)
- Prevents accidental duplicate creation
- Location: QuickAddIngredientModal.tsx
**2. Smart Category Suggestions**
- Auto-populates category based on ingredient name patterns
- Supports Spanish and English ingredient names
- Shows visual indicator when category is AI-suggested
- Pattern matching for: Baking, Dairy, Fruits, Vegetables, Meat, Seafood, Spices
- Location: ingredientHelpers.ts
**3. Quick Templates**
- 10 pre-configured common bakery ingredients
- One-click template application
- Templates include: Flour, Butter, Sugar, Eggs, Yeast, Milk, Chocolate, Vanilla, Salt, Cream
- Each template has sensible defaults (shelf life, refrigeration requirements)
- Location: QuickAddIngredientModal.tsx
**4. Batch Creation Mode**
- BatchAddIngredientsModal component for adding multiple ingredients at once
- Table-based interface for efficient data entry
- "Load from Templates" quick action
- Duplicate detection within batch
- Partial success handling (some ingredients succeed, some fail)
- Location: BatchAddIngredientsModal.tsx
- Integration: UploadSalesDataStep.tsx (2 buttons: "Add One" / "Add Multiple")
**5. Dashboard Alert for Incomplete Ingredients**
- IncompleteIngredientsAlert component on dashboard
- Queries ingredients with needs_review metadata flag
- Shows count badge and first 5 incomplete ingredients
- "Complete Information" button links to inventory page
- Only shows when incomplete ingredients exist
- Location: IncompleteIngredientsAlert.tsx
- Integration: DashboardPage.tsx
**New Files Created:**
- ingredientHelpers.ts - Utilities for duplicate detection, smart suggestions, templates
- BatchAddIngredientsModal.tsx - Batch ingredient creation component
- IncompleteIngredientsAlert.tsx - Dashboard alert component
**Files Modified:**
- QuickAddIngredientModal.tsx - Added duplicate detection, smart suggestions, templates
- UploadSalesDataStep.tsx - Integrated batch creation modal
- DashboardPage.tsx - Added incomplete ingredients alert
**Technical Highlights:**
- Levenshtein distance algorithm for fuzzy name matching
- Pattern-based category suggestions (supports 100+ ingredient patterns)
- Metadata tracking (needs_review, created_context)
- Real-time validation and error handling
- Responsive UI with animations
- Consistent with existing design system
All features built and tested successfully.
Build time: 21.29s
2025-11-06 15:39:30 +00:00
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIsAdding(true)}
|
|
|
|
|
className="p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
|
|
|
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
Agregar Uno
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowBatchModal(true)}
|
|
|
|
|
className="p-4 border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5 transition-colors group"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-center gap-2 text-[var(--color-primary)]">
|
|
|
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
Agregar Varios
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-11-06 14:36:10 +00:00
|
|
|
)}
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4">
|
|
|
|
|
<p className="text-[var(--color-error)]">{error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
{/* Progress during creation */}
|
|
|
|
|
{progressState && (
|
|
|
|
|
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
|
|
|
|
<div className="flex justify-between text-sm mb-2">
|
|
|
|
|
<span className="font-medium">{progressState.message}</span>
|
|
|
|
|
<span>{progressState.progress}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
|
|
|
|
<div
|
|
|
|
|
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
|
|
|
|
style={{ width: `${progressState.progress}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Navigation - Show Next button when minimum requirement met */}
|
|
|
|
|
{!isAdding && (
|
|
|
|
|
<div className="flex items-center justify-between pt-6 border-t border-[var(--border-secondary)] mt-6">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{canContinue ? (
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-[var(--color-success)]">
|
|
|
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span>
|
|
|
|
|
{inventoryItems.length} ingrediente(s) - ¡Listo para continuar!
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-sm text-[var(--color-warning)]">
|
|
|
|
|
Agrega al menos 1 ingrediente para continuar
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
disabled={!canContinue || !!progressState}
|
|
|
|
|
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
|
|
|
|
|
>
|
|
|
|
|
{progressState ? 'Creando...' : 'Siguiente'}
|
|
|
|
|
→
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
Implement 5 UX enhancements for ingredient management
This commit implements the requested enhancements for the ingredient
quick-add system and batch management:
**1. Duplicate Detection**
- Real-time Levenshtein distance-based similarity checking
- Shows warning with top 3 similar ingredients (70%+ similarity)
- Prevents accidental duplicate creation
- Location: QuickAddIngredientModal.tsx
**2. Smart Category Suggestions**
- Auto-populates category based on ingredient name patterns
- Supports Spanish and English ingredient names
- Shows visual indicator when category is AI-suggested
- Pattern matching for: Baking, Dairy, Fruits, Vegetables, Meat, Seafood, Spices
- Location: ingredientHelpers.ts
**3. Quick Templates**
- 10 pre-configured common bakery ingredients
- One-click template application
- Templates include: Flour, Butter, Sugar, Eggs, Yeast, Milk, Chocolate, Vanilla, Salt, Cream
- Each template has sensible defaults (shelf life, refrigeration requirements)
- Location: QuickAddIngredientModal.tsx
**4. Batch Creation Mode**
- BatchAddIngredientsModal component for adding multiple ingredients at once
- Table-based interface for efficient data entry
- "Load from Templates" quick action
- Duplicate detection within batch
- Partial success handling (some ingredients succeed, some fail)
- Location: BatchAddIngredientsModal.tsx
- Integration: UploadSalesDataStep.tsx (2 buttons: "Add One" / "Add Multiple")
**5. Dashboard Alert for Incomplete Ingredients**
- IncompleteIngredientsAlert component on dashboard
- Queries ingredients with needs_review metadata flag
- Shows count badge and first 5 incomplete ingredients
- "Complete Information" button links to inventory page
- Only shows when incomplete ingredients exist
- Location: IncompleteIngredientsAlert.tsx
- Integration: DashboardPage.tsx
**New Files Created:**
- ingredientHelpers.ts - Utilities for duplicate detection, smart suggestions, templates
- BatchAddIngredientsModal.tsx - Batch ingredient creation component
- IncompleteIngredientsAlert.tsx - Dashboard alert component
**Files Modified:**
- QuickAddIngredientModal.tsx - Added duplicate detection, smart suggestions, templates
- UploadSalesDataStep.tsx - Integrated batch creation modal
- DashboardPage.tsx - Added incomplete ingredients alert
**Technical Highlights:**
- Levenshtein distance algorithm for fuzzy name matching
- Pattern-based category suggestions (supports 100+ ingredient patterns)
- Metadata tracking (needs_review, created_context)
- Real-time validation and error handling
- Responsive UI with animations
- Consistent with existing design system
All features built and tested successfully.
Build time: 21.29s
2025-11-06 15:39:30 +00:00
|
|
|
|
|
|
|
|
{/* Batch Add Modal */}
|
|
|
|
|
<BatchAddIngredientsModal
|
|
|
|
|
isOpen={showBatchModal}
|
|
|
|
|
onClose={() => setShowBatchModal(false)}
|
|
|
|
|
onCreated={(ingredients) => {
|
|
|
|
|
// Add all created ingredients to the list
|
|
|
|
|
const newItems: InventoryItemForm[] = ingredients.map(ing => ({
|
|
|
|
|
id: ing.id,
|
|
|
|
|
name: ing.name,
|
|
|
|
|
product_type: ing.product_type,
|
|
|
|
|
category: ing.category,
|
|
|
|
|
unit_of_measure: ing.unit_of_measure,
|
|
|
|
|
stock_quantity: 0,
|
|
|
|
|
cost_per_unit: ing.average_cost || 0,
|
|
|
|
|
estimated_shelf_life_days: ing.shelf_life_days || 30,
|
|
|
|
|
requires_refrigeration: ing.requires_refrigeration || false,
|
|
|
|
|
requires_freezing: ing.requires_freezing || false,
|
|
|
|
|
is_seasonal: ing.is_seasonal || false,
|
|
|
|
|
low_stock_threshold: ing.low_stock_threshold || 0,
|
|
|
|
|
reorder_point: ing.reorder_point || 0,
|
|
|
|
|
notes: ing.notes || '',
|
|
|
|
|
isSuggested: false,
|
|
|
|
|
}));
|
|
|
|
|
setInventoryItems([...inventoryItems, ...newItems]);
|
|
|
|
|
setShowBatchModal(false);
|
|
|
|
|
}}
|
|
|
|
|
tenantId={currentTenant?.id || ''}
|
|
|
|
|
/>
|
2025-09-08 17:19:00 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 15:09:23 +00:00
|
|
|
// FILE UPLOAD VIEW (initial step)
|
2025-09-08 17:19:00 +02:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<p className="text-[var(--text-secondary)] mb-6">
|
2025-09-08 22:28:26 +02:00
|
|
|
Sube tus datos de ventas (formato CSV o JSON) y automáticamente validaremos y generaremos sugerencias de inventario inteligentes.
|
2025-09-08 17:19:00 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-19 19:22:37 +02:00
|
|
|
{/* File Format Guide */}
|
2025-11-06 14:36:10 +00:00
|
|
|
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
|
2025-10-19 19:22:37 +02:00
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
2025-11-06 14:36:10 +00:00
|
|
|
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
2025-10-19 19:22:37 +02:00
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
2025-11-06 14:36:10 +00:00
|
|
|
<h3 className="font-semibold text-[var(--text-primary)]">
|
2025-10-19 19:22:37 +02:00
|
|
|
{t('onboarding:steps.inventory_setup.file_format_guide.title', 'Guía de Formato de Archivo')}
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowGuide(!showGuide)}
|
2025-11-06 14:36:10 +00:00
|
|
|
className="text-[var(--color-info)] hover:text-[var(--color-primary)] text-sm font-medium"
|
2025-10-19 19:22:37 +02:00
|
|
|
>
|
2025-11-06 15:09:23 +00:00
|
|
|
{showGuide ? 'Ocultar Guía' : 'Ver Guía Completa'}
|
2025-10-19 19:22:37 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-06 14:36:10 +00:00
|
|
|
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
2025-10-19 19:22:37 +02:00
|
|
|
<p>
|
2025-11-06 15:09:23 +00:00
|
|
|
<strong className="text-[var(--text-primary)]">Formatos Soportados:</strong>{' '}
|
|
|
|
|
CSV, JSON, Excel (XLSX) • Tamaño máximo: 10MB
|
2025-10-19 19:22:37 +02:00
|
|
|
</p>
|
|
|
|
|
<p>
|
2025-11-06 15:09:23 +00:00
|
|
|
<strong className="text-[var(--text-primary)]">Columnas Requeridas:</strong>{' '}
|
|
|
|
|
Fecha, Nombre del Producto, Cantidad Vendida
|
2025-10-19 19:22:37 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showGuide && (
|
2025-11-06 15:09:23 +00:00
|
|
|
<div className="mt-4 pt-4 border-t border-[var(--border-secondary)] space-y-3 text-sm text-[var(--text-secondary)]">
|
|
|
|
|
<p>✓ Detección multiidioma de columnas</p>
|
|
|
|
|
<p>✓ Validación automática con reporte detallado</p>
|
|
|
|
|
<p>✓ Clasificación de productos con IA</p>
|
|
|
|
|
<p>✓ Sugerencias inteligentes de inventario</p>
|
2025-10-19 19:22:37 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-08 17:19:00 +02:00
|
|
|
{/* File Upload Area */}
|
|
|
|
|
<div
|
|
|
|
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
|
|
|
|
selectedFile
|
|
|
|
|
? 'border-[var(--color-success)] bg-[var(--color-success)]/5'
|
|
|
|
|
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5'
|
|
|
|
|
}`}
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept=".csv,.json"
|
|
|
|
|
onChange={handleFileSelect}
|
|
|
|
|
className="hidden"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{selectedFile ? (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="text-[var(--color-success)]">
|
|
|
|
|
<svg className="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
<p className="text-lg font-medium">Archivo Seleccionado</p>
|
|
|
|
|
<p className="text-[var(--text-secondary)]">{selectedFile.name}</p>
|
|
|
|
|
<p className="text-sm text-[var(--text-tertiary)]">
|
|
|
|
|
{formatFileSize(selectedFile.size)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
>
|
2025-11-06 15:09:23 +00:00
|
|
|
Elegir Archivo Diferente
|
2025-09-08 17:19:00 +02:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<svg className="mx-auto h-12 w-12 text-[var(--text-tertiary)]" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
|
|
|
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
<div>
|
2025-11-06 15:09:23 +00:00
|
|
|
<p className="text-lg font-medium">Arrastra tus datos de ventas aquí</p>
|
|
|
|
|
<p className="text-[var(--text-secondary)]">o haz clic para seleccionar archivos</p>
|
2025-09-08 17:19:00 +02:00
|
|
|
<p className="text-sm text-[var(--text-tertiary)] mt-2">
|
2025-11-06 15:09:23 +00:00
|
|
|
Formatos soportados: CSV, JSON (máx 100MB)<br/>
|
|
|
|
|
<span className="text-[var(--color-primary)]">Validación y sugerencias automáticas</span>
|
2025-09-08 17:19:00 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
>
|
2025-11-06 15:09:23 +00:00
|
|
|
Elegir Archivo
|
2025-09-08 17:19:00 +02:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Progress */}
|
|
|
|
|
{progressState && (
|
|
|
|
|
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
|
|
|
|
<div className="flex justify-between text-sm mb-2">
|
|
|
|
|
<span className="font-medium">{progressState.message}</span>
|
|
|
|
|
<span>{progressState.progress}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
|
|
|
|
<div
|
|
|
|
|
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
|
|
|
|
style={{ width: `${progressState.progress}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Validation Results */}
|
|
|
|
|
{validationResult && (
|
|
|
|
|
<div className="bg-[var(--color-success)]/10 border border-[var(--color-success)]/20 rounded-lg p-4">
|
2025-11-06 15:09:23 +00:00
|
|
|
<h3 className="font-semibold text-[var(--color-success)] mb-2">¡Validación Exitosa!</h3>
|
2025-09-08 17:19:00 +02:00
|
|
|
<div className="space-y-2 text-sm">
|
2025-11-06 15:09:23 +00:00
|
|
|
<p>Registros totales: {validationResult.total_records}</p>
|
|
|
|
|
<p>Registros válidos: {validationResult.valid_records}</p>
|
2025-09-08 17:19:00 +02:00
|
|
|
{validationResult.invalid_records > 0 && (
|
|
|
|
|
<p className="text-[var(--color-warning)]">
|
2025-11-06 15:09:23 +00:00
|
|
|
Registros inválidos: {validationResult.invalid_records}
|
2025-09-08 17:19:00 +02:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Error */}
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4">
|
|
|
|
|
<p className="text-[var(--color-error)]">{error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-06 14:36:10 +00:00
|
|
|
{/* Status indicator */}
|
|
|
|
|
{selectedFile && !showInventoryStep && (
|
|
|
|
|
<div className="flex items-center justify-center px-4 py-2 bg-[var(--bg-secondary)] rounded-lg">
|
|
|
|
|
{isValidating ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[var(--color-primary)] mr-2"></div>
|
|
|
|
|
<span className="text-sm text-[var(--text-secondary)]">Procesando automáticamente...</span>
|
|
|
|
|
</>
|
|
|
|
|
) : validationResult ? (
|
|
|
|
|
<>
|
|
|
|
|
<svg className="w-4 h-4 text-[var(--color-success)] mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span className="text-sm text-[var(--color-success)]">Archivo procesado exitosamente</span>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-08 17:19:00 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-11-06 15:09:23 +00:00
|
|
|
};
|