IMPORVE ONBOARDING STEPS

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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