Imporve onboarding UI

This commit is contained in:
Urtzi Alfaro
2025-12-19 13:10:24 +01:00
parent 71ee2976a2
commit bfa5ff0637
39 changed files with 1016 additions and 483 deletions

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateIngredient } from '../../../../api/hooks/inventory';
import { useCreateIngredient, useIngredients } from '../../../../api/hooks/inventory';
import { useImportSalesData } from '../../../../api/hooks/sales';
import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory';
import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory';
@@ -14,6 +14,7 @@ interface InventoryReviewStepProps {
onComplete: (data: {
inventoryItemsCreated: number;
salesDataImported: boolean;
inventoryItems?: any[];
}) => void;
isFirstStep: boolean;
isLastStep: boolean;
@@ -140,11 +141,15 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
// API hooks
const createIngredientMutation = useCreateIngredient();
const importSalesMutation = useImportSalesData();
const { data: existingIngredients } = useIngredients(tenantId);
// Initialize with AI suggestions
// Initialize with AI suggestions AND existing ingredients
useEffect(() => {
if (initialData?.aiSuggestions) {
const items: InventoryItemForm[] = initialData.aiSuggestions.map((suggestion, index) => ({
// 1. Start with AI suggestions if available
let items: InventoryItemForm[] = [];
if (initialData?.aiSuggestions && initialData.aiSuggestions.length > 0) {
items = initialData.aiSuggestions.map((suggestion, index) => ({
id: `ai-${index}-${Date.now()}`,
name: suggestion.suggested_name,
product_type: suggestion.product_type as ProductType,
@@ -157,9 +162,43 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
average_daily_sales: suggestion.sales_data.average_daily_sales,
} : undefined,
}));
}
// 2. Merge/Override with existing backend ingredients
if (existingIngredients && existingIngredients.length > 0) {
existingIngredients.forEach(ing => {
// Check if we already have this by name (from AI)
const existingIdx = items.findIndex(item =>
item.name.toLowerCase() === ing.name.toLowerCase() &&
item.product_type === ing.product_type
);
if (existingIdx !== -1) {
// Update existing item with real ID
items[existingIdx] = {
...items[existingIdx],
id: ing.id,
category: ing.category || items[existingIdx].category,
unit_of_measure: ing.unit_of_measure as UnitOfMeasure,
};
} else {
// Add as new item (this handles items created in previous sessions/attempts)
items.push({
id: ing.id,
name: ing.name,
product_type: ing.product_type,
category: ing.category || '',
unit_of_measure: ing.unit_of_measure as UnitOfMeasure,
isSuggested: false,
});
}
});
}
if (items.length > 0) {
setInventoryItems(items);
}
}, [initialData]);
}, [initialData, existingIngredients]);
// Filter items
const filteredItems = inventoryItems.filter(item => {
@@ -277,43 +316,45 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
setFormErrors({});
try {
// STEP 1: Create all inventory items in parallel
// This MUST happen BEFORE sales import because sales records reference inventory IDs
console.log('📦 Creating inventory items...', inventoryItems.length);
console.log('📋 Items to create:', inventoryItems.map(item => ({
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure
})));
// STEP 0: Check for existing ingredients to avoid duplication
const existingNamesAndTypes = new Set(
existingIngredients?.map(i => `${i.name.toLowerCase()}-${i.product_type}`) || []
);
const createPromises = inventoryItems.map((item, index) => {
const itemsToCreate = inventoryItems.filter(item => {
const key = `${item.name.toLowerCase()}-${item.product_type}`;
return !existingNamesAndTypes.has(key);
});
const existingMatches = existingIngredients?.filter(i => {
const key = `${i.name.toLowerCase()}-${i.product_type}`;
return inventoryItems.some(item => `${item.name.toLowerCase()}-${item.product_type}` === key);
}) || [];
console.log(`📦 Inventory processing: ${itemsToCreate.length} to create, ${existingMatches.length} already exist.`);
// STEP 1: Create new inventory items in parallel
const createPromises = itemsToCreate.map((item, index) => {
const ingredientData: IngredientCreate = {
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure as UnitOfMeasure,
// All other fields are optional now!
};
console.log(`🔄 Creating ingredient ${index + 1}/${inventoryItems.length}:`, ingredientData);
return createIngredientMutation.mutateAsync({
tenantId,
ingredientData,
}).catch(error => {
console.error(`❌ Failed to create ingredient "${item.name}":`, error);
console.error('Failed ingredient data:', ingredientData);
throw error;
});
});
const createdIngredients = await Promise.all(createPromises);
console.log('✅ Inventory items created successfully');
console.log('📋 Created ingredient IDs:', createdIngredients.map(ing => ({ name: ing.name, id: ing.id })));
const newlyCreatedIngredients = await Promise.all(createPromises);
console.log('✅ New inventory items created successfully');
// STEP 2: Import sales data (only if file was uploaded)
// Now that inventory exists, sales records can reference the inventory IDs
let salesImported = false;
if (initialData?.uploadedFile && tenantId) {
try {
@@ -325,28 +366,34 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
salesImported = true;
console.log('✅ Sales data imported successfully');
} catch (salesError) {
console.error('⚠️ Sales import failed (non-blocking):', salesError);
// Don't block onboarding if sales import fails
// Inventory is already created, which is the critical part
console.error('⚠️ Sales import failed (non-blocking):', salesError);
}
}
// Complete the step with metadata and inventory items
// Map created ingredients to include their real UUIDs
const itemsWithRealIds = createdIngredients.map(ingredient => ({
id: ingredient.id, // Real UUID from the API
name: ingredient.name,
product_type: ingredient.product_type,
category: ingredient.category,
unit_of_measure: ingredient.unit_of_measure,
}));
// STEP 3: Consolidate all items (existing + newly created)
const allItemsWithRealIds = [
...existingMatches.map(i => ({
id: i.id,
name: i.name,
product_type: i.product_type,
category: i.category,
unit_of_measure: i.unit_of_measure,
})),
...newlyCreatedIngredients.map(i => ({
id: i.id,
name: i.name,
product_type: i.product_type,
category: i.category,
unit_of_measure: i.unit_of_measure,
}))
];
console.log('📦 Passing items with real IDs to next step:', itemsWithRealIds);
console.log('📦 Passing items with real IDs to next step:', allItemsWithRealIds);
onComplete({
inventoryItemsCreated: createdIngredients.length,
inventoryItemsCreated: newlyCreatedIngredients.length,
salesDataImported: salesImported,
inventoryItems: itemsWithRealIds, // Pass the created items with real UUIDs to the next step
inventoryItems: allItemsWithRealIds,
});
} catch (error) {
console.error('Error creating inventory items:', error);
@@ -439,21 +486,19 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
<div className="flex gap-2 border-b border-[var(--border-color)] overflow-x-auto scrollbar-hide -mx-2 px-2">
<button
onClick={() => setActiveFilter('all')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative whitespace-nowrap text-sm md:text-base ${
activeFilter === 'all'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative whitespace-nowrap text-sm md:text-base ${activeFilter === 'all'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
{t('inventory:filter.all', 'Todos')} ({counts.all})
</button>
<button
onClick={() => setActiveFilter('finished_products')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
activeFilter === 'finished_products'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${activeFilter === 'finished_products'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
<ShoppingBag className="w-5 h-5" />
<span className="hidden sm:inline">{t('inventory:filter.finished_products', 'Productos Terminados')}</span>
@@ -461,11 +506,10 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
</button>
<button
onClick={() => setActiveFilter('ingredients')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
activeFilter === 'ingredients'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${activeFilter === 'ingredients'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
<Package className="w-5 h-5" />
{t('inventory:filter.ingredients', 'Ingredientes')} ({counts.ingredients})
@@ -492,11 +536,10 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
<h5 className="font-medium text-[var(--text-primary)] truncate">{item.name}</h5>
{/* Product Type Badge */}
<span className={`text-xs px-2 py-0.5 rounded-full ${
item.product_type === ProductType.FINISHED_PRODUCT
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
<span className={`text-xs px-2 py-0.5 rounded-full ${item.product_type === ProductType.FINISHED_PRODUCT
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
{item.product_type === ProductType.FINISHED_PRODUCT ? (
<span className="flex items-center gap-1">
<ShoppingBag className="w-3 h-3" />
@@ -579,11 +622,10 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.INGREDIENT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
@@ -600,11 +642,10 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.FINISHED_PRODUCT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
@@ -724,11 +765,10 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.INGREDIENT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
@@ -745,11 +785,10 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.FINISHED_PRODUCT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
@@ -862,9 +901,9 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
{isSubmitting
? t('common:saving', 'Guardando...')
: <>
<span className="hidden md:inline">{t('common:continue', 'Continuar')} ({inventoryItems.length} {t('common:items', 'productos')}) </span>
<span className="md:hidden">{t('common:continue', 'Continuar')} ({inventoryItems.length}) </span>
</>
<span className="hidden md:inline">{t('common:continue', 'Continuar')} ({inventoryItems.length} {t('common:items', 'productos')}) </span>
<span className="md:hidden">{t('common:continue', 'Continuar')} ({inventoryItems.length}) </span>
</>
}
</Button>
</div>