IMPORVE ONBOARDING STEPS
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../../ui/Button';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useClassifyBatch } from '../../../../api/hooks/inventory';
|
||||
import { useValidateImportFile } from '../../../../api/hooks/sales';
|
||||
import type { ImportValidationResponse } from '../../../../api/types/dataImport';
|
||||
import type { ProductSuggestionResponse } from '../../../../api/types/inventory';
|
||||
import { Upload, FileText, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
interface FileUploadStepProps {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onComplete: (data: {
|
||||
uploadedFile: File; // NEW: Pass the file object for sales import
|
||||
validationResult: ImportValidationResponse;
|
||||
aiSuggestions: ProductSuggestionResponse[];
|
||||
uploadedFileName: string;
|
||||
uploadedFileSize: number;
|
||||
}) => void;
|
||||
isFirstStep: boolean;
|
||||
isLastStep: boolean;
|
||||
}
|
||||
|
||||
interface ProgressState {
|
||||
stage: 'preparing' | 'validating' | 'analyzing' | 'classifying';
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const FileUploadStep: React.FC<FileUploadStepProps> = ({
|
||||
onComplete,
|
||||
onPrevious,
|
||||
isFirstStep
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [progressState, setProgressState] = useState<ProgressState | null>(null);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// API hooks
|
||||
const validateFileMutation = useValidateImportFile();
|
||||
const classifyBatchMutation = useClassifyBatch();
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleUploadAndProcess = async () => {
|
||||
if (!selectedFile || !currentTenant?.id) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setError('');
|
||||
setProgressState({
|
||||
stage: 'preparing',
|
||||
progress: 10,
|
||||
message: t('onboarding:file_upload.preparing', 'Preparando archivo...')
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 1: Validate the file
|
||||
setProgressState({
|
||||
stage: 'validating',
|
||||
progress: 30,
|
||||
message: t('onboarding:file_upload.validating', 'Validando formato del archivo...')
|
||||
});
|
||||
|
||||
const validationResult = await validateFileMutation.mutateAsync({
|
||||
tenantId: currentTenant.id,
|
||||
file: selectedFile
|
||||
});
|
||||
|
||||
if (!validationResult || validationResult.is_valid === undefined) {
|
||||
throw new Error('Invalid validation response from server');
|
||||
}
|
||||
|
||||
if (!validationResult.is_valid) {
|
||||
const errorMsg = validationResult.errors?.join(', ') || 'Archivo inválido';
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Step 2: Extract product list
|
||||
setProgressState({
|
||||
stage: 'analyzing',
|
||||
progress: 50,
|
||||
message: t('onboarding:file_upload.analyzing', 'Analizando productos en el archivo...')
|
||||
});
|
||||
|
||||
const products = validationResult.product_list?.map((productName: string) => ({
|
||||
product_name: productName
|
||||
})) || [];
|
||||
|
||||
if (products.length === 0) {
|
||||
throw new Error(t('onboarding:file_upload.no_products', 'No se encontraron productos en el archivo'));
|
||||
}
|
||||
|
||||
// Step 3: AI Classification
|
||||
setProgressState({
|
||||
stage: 'classifying',
|
||||
progress: 75,
|
||||
message: t('onboarding:file_upload.classifying', `Clasificando ${products.length} productos con IA...`)
|
||||
});
|
||||
|
||||
const classificationResponse = await classifyBatchMutation.mutateAsync({
|
||||
tenantId: currentTenant.id,
|
||||
products
|
||||
});
|
||||
|
||||
// Step 4: Complete with success
|
||||
setProgressState({
|
||||
stage: 'classifying',
|
||||
progress: 100,
|
||||
message: t('onboarding:file_upload.success', '¡Análisis completado!')
|
||||
});
|
||||
|
||||
// Pass data to parent and move to next step
|
||||
setTimeout(() => {
|
||||
onComplete({
|
||||
uploadedFile: selectedFile, // NEW: Pass the file for sales import
|
||||
validationResult,
|
||||
aiSuggestions: classificationResponse.suggestions,
|
||||
uploadedFileName: selectedFile.name,
|
||||
uploadedFileSize: selectedFile.size,
|
||||
});
|
||||
}, 500);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error processing file:', err);
|
||||
setError(err instanceof Error ? err.message : 'Error procesando archivo');
|
||||
setProgressState(null);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setError('');
|
||||
setProgressState(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-2">
|
||||
{t('onboarding:file_upload.title', 'Subir Datos de Ventas')}
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-[var(--text-secondary)]">
|
||||
{t('onboarding:file_upload.description', 'Sube un archivo con tus datos de ventas y nuestro sistema detectará automáticamente tus productos')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Why This Matters */}
|
||||
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-info)]" />
|
||||
{t('setup_wizard:why_this_matters', '¿Por qué es importante?')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('onboarding:file_upload.why', 'Analizaremos tus datos de ventas históricas para configurar automáticamente tu inventario inicial con inteligencia artificial, ahorrándote horas de trabajo manual.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File Upload Area */}
|
||||
{!selectedFile && !isProcessing && (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg p-6 md:p-8 text-center hover:border-[var(--color-primary)]/50 transition-colors cursor-pointer min-h-[200px] flex flex-col items-center justify-center"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-10 h-10 md:w-12 md:h-12 mx-auto mb-3 md:mb-4 text-[var(--color-primary)]/50" />
|
||||
<h3 className="text-base md:text-lg font-medium text-[var(--text-primary)] mb-2 px-4">
|
||||
{t('onboarding:file_upload.drop_zone_title', 'Arrastra tu archivo aquí')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3 md:mb-4">
|
||||
{t('onboarding:file_upload.drop_zone_subtitle', 'o haz clic para seleccionar')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] px-4">
|
||||
{t('onboarding:file_upload.formats', 'Formatos soportados: CSV, JSON (máx. 10MB)')}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected File Preview */}
|
||||
{selectedFile && !isProcessing && (
|
||||
<div className="border border-[var(--color-success)] bg-[var(--color-success)]/5 rounded-lg p-3 md:p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
<FileText className="w-8 h-8 md:w-10 md:h-10 text-[var(--color-success)] flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-[var(--text-primary)] text-sm md:text-base truncate">{selectedFile.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{(selectedFile.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveFile}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] p-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Indicator */}
|
||||
{isProcessing && progressState && (
|
||||
<div className="border border-[var(--color-primary)] rounded-lg p-6 bg-[var(--color-primary)]/5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[var(--color-primary)]" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{progressState.message}</p>
|
||||
<div className="mt-2 bg-[var(--bg-secondary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressState.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] text-center">
|
||||
{progressState.progress}% completado
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-danger)] flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--color-danger)] mb-1">Error</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Guide Toggle */}
|
||||
<button
|
||||
onClick={() => setShowGuide(!showGuide)}
|
||||
className="text-sm text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
{showGuide ? '▼' : '▶'} {t('onboarding:file_upload.show_guide', '¿Necesitas ayuda con el formato del archivo?')}
|
||||
</button>
|
||||
|
||||
{showGuide && (
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 text-sm space-y-2">
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{t('onboarding:file_upload.guide_title', 'Formato requerido del archivo:')}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-[var(--text-secondary)]">
|
||||
<li>{t('onboarding:file_upload.guide_1', 'Columnas: Fecha, Producto, Cantidad')}</li>
|
||||
<li>{t('onboarding:file_upload.guide_2', 'Formato de fecha: YYYY-MM-DD')}</li>
|
||||
<li>{t('onboarding:file_upload.guide_3', 'Los nombres de productos deben ser consistentes')}</li>
|
||||
<li>{t('onboarding:file_upload.guide_4', 'Ejemplo: 2024-01-15,Pan de Molde,25')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between gap-4 pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isProcessing || isFirstStep}
|
||||
>
|
||||
{t('common:back', '← Atrás')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUploadAndProcess}
|
||||
disabled={!selectedFile || isProcessing}
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('onboarding:file_upload.processing', 'Procesando...')}
|
||||
</>
|
||||
) : (
|
||||
t('onboarding:file_upload.continue', 'Analizar y Continuar →')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export interface ProductWithStock {
|
||||
}
|
||||
|
||||
export interface InitialStockEntryStepProps {
|
||||
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'}
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../../ui/Button';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useCreateIngredient } from '../../../../api/hooks/inventory';
|
||||
import { useImportSalesData } from '../../../../api/hooks/sales';
|
||||
import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory';
|
||||
import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory';
|
||||
import { Package, ShoppingBag, AlertCircle, CheckCircle2, Edit2, Trash2, Plus, Sparkles } from 'lucide-react';
|
||||
|
||||
interface InventoryReviewStepProps {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onComplete: (data: {
|
||||
inventoryItemsCreated: number;
|
||||
salesDataImported: boolean;
|
||||
}) => void;
|
||||
isFirstStep: boolean;
|
||||
isLastStep: boolean;
|
||||
initialData?: {
|
||||
uploadedFile?: File; // NEW: File object for sales import
|
||||
validationResult?: any; // NEW: Validation result
|
||||
aiSuggestions: ProductSuggestionResponse[];
|
||||
uploadedFileName: string;
|
||||
uploadedFileSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface InventoryItemForm {
|
||||
id: string; // Unique ID for UI tracking
|
||||
name: string;
|
||||
product_type: ProductType;
|
||||
category: string;
|
||||
unit_of_measure: UnitOfMeasure | string;
|
||||
// AI suggestion metadata (if from AI)
|
||||
isSuggested: boolean;
|
||||
confidence_score?: number;
|
||||
sales_data?: {
|
||||
total_quantity: number;
|
||||
average_daily_sales: number;
|
||||
};
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'ingredients' | 'finished_products';
|
||||
|
||||
// Template Definitions - Common Bakery Ingredients
|
||||
interface TemplateItem {
|
||||
name: string;
|
||||
product_type: ProductType;
|
||||
category: string;
|
||||
unit_of_measure: UnitOfMeasure;
|
||||
}
|
||||
|
||||
interface IngredientTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
items: TemplateItem[];
|
||||
}
|
||||
|
||||
const INGREDIENT_TEMPLATES: IngredientTemplate[] = [
|
||||
{
|
||||
id: 'basic-bakery',
|
||||
name: 'Ingredientes Básicos de Panadería',
|
||||
description: 'Esenciales para cualquier panadería',
|
||||
icon: '🍞',
|
||||
items: [
|
||||
{ name: 'Harina de Trigo', product_type: ProductType.INGREDIENT, category: IngredientCategory.FLOUR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Azúcar', product_type: ProductType.INGREDIENT, category: IngredientCategory.SUGAR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Sal', product_type: ProductType.INGREDIENT, category: IngredientCategory.SALT, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Levadura Fresca', product_type: ProductType.INGREDIENT, category: IngredientCategory.YEAST, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Agua', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.LITERS },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pastry-essentials',
|
||||
name: 'Esenciales para Pastelería',
|
||||
description: 'Ingredientes para pasteles y postres',
|
||||
icon: '🎂',
|
||||
items: [
|
||||
{ name: 'Huevos', product_type: ProductType.INGREDIENT, category: IngredientCategory.EGGS, unit_of_measure: UnitOfMeasure.UNITS },
|
||||
{ name: 'Mantequilla', product_type: ProductType.INGREDIENT, category: IngredientCategory.FATS, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Leche', product_type: ProductType.INGREDIENT, category: IngredientCategory.DAIRY, unit_of_measure: UnitOfMeasure.LITERS },
|
||||
{ name: 'Vainilla', product_type: ProductType.INGREDIENT, category: IngredientCategory.SPICES, unit_of_measure: UnitOfMeasure.MILLILITERS },
|
||||
{ name: 'Azúcar Glass', product_type: ProductType.INGREDIENT, category: IngredientCategory.SUGAR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bread-basics',
|
||||
name: 'Básicos para Pan Artesanal',
|
||||
description: 'Todo lo necesario para pan artesanal',
|
||||
icon: '🥖',
|
||||
items: [
|
||||
{ name: 'Harina Integral', product_type: ProductType.INGREDIENT, category: IngredientCategory.FLOUR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Masa Madre', product_type: ProductType.INGREDIENT, category: IngredientCategory.YEAST, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Aceite de Oliva', product_type: ProductType.INGREDIENT, category: IngredientCategory.FATS, unit_of_measure: UnitOfMeasure.LITERS },
|
||||
{ name: 'Semillas de Sésamo', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chocolate-specialties',
|
||||
name: 'Especialidades de Chocolate',
|
||||
description: 'Para productos con chocolate',
|
||||
icon: '🍫',
|
||||
items: [
|
||||
{ name: 'Chocolate Negro', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Cacao en Polvo', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Chocolate con Leche', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
{ name: 'Crema de Avellanas', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
|
||||
onComplete,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
initialData
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItemForm[]>([]);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<InventoryItemForm>({
|
||||
id: '',
|
||||
name: '',
|
||||
product_type: ProductType.INGREDIENT,
|
||||
category: '',
|
||||
unit_of_measure: UnitOfMeasure.KILOGRAMS,
|
||||
isSuggested: false,
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// API hooks
|
||||
const createIngredientMutation = useCreateIngredient();
|
||||
const importSalesMutation = useImportSalesData();
|
||||
|
||||
// Initialize with AI suggestions
|
||||
useEffect(() => {
|
||||
if (initialData?.aiSuggestions) {
|
||||
const items: InventoryItemForm[] = initialData.aiSuggestions.map((suggestion, index) => ({
|
||||
id: `ai-${index}-${Date.now()}`,
|
||||
name: suggestion.suggested_name,
|
||||
product_type: suggestion.product_type as ProductType,
|
||||
category: suggestion.category,
|
||||
unit_of_measure: suggestion.unit_of_measure as UnitOfMeasure,
|
||||
isSuggested: true,
|
||||
confidence_score: suggestion.confidence_score,
|
||||
sales_data: suggestion.sales_data ? {
|
||||
total_quantity: suggestion.sales_data.total_quantity,
|
||||
average_daily_sales: suggestion.sales_data.average_daily_sales,
|
||||
} : undefined,
|
||||
}));
|
||||
setInventoryItems(items);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// Filter items
|
||||
const filteredItems = inventoryItems.filter(item => {
|
||||
if (activeFilter === 'ingredients') return item.product_type === ProductType.INGREDIENT;
|
||||
if (activeFilter === 'finished_products') return item.product_type === ProductType.FINISHED_PRODUCT;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Count by type
|
||||
const counts = {
|
||||
all: inventoryItems.length,
|
||||
ingredients: inventoryItems.filter(i => i.product_type === ProductType.INGREDIENT).length,
|
||||
finished_products: inventoryItems.filter(i => i.product_type === ProductType.FINISHED_PRODUCT).length,
|
||||
};
|
||||
|
||||
// Form handlers
|
||||
const handleAdd = () => {
|
||||
setFormData({
|
||||
id: `manual-${Date.now()}`,
|
||||
name: '',
|
||||
product_type: ProductType.INGREDIENT,
|
||||
category: '',
|
||||
unit_of_measure: UnitOfMeasure.KILOGRAMS,
|
||||
isSuggested: false,
|
||||
});
|
||||
setEditingId(null);
|
||||
setIsAdding(true);
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
const handleEdit = (item: InventoryItemForm) => {
|
||||
setFormData({ ...item });
|
||||
setEditingId(item.id);
|
||||
setIsAdding(true);
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setInventoryItems(items => items.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
errors.name = t('validation:name_required', 'El nombre es requerido');
|
||||
}
|
||||
|
||||
if (!formData.category) {
|
||||
errors.category = t('validation:category_required', 'La categoría es requerida');
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
if (editingId) {
|
||||
// Update existing
|
||||
setInventoryItems(items =>
|
||||
items.map(item => (item.id === editingId ? formData : item))
|
||||
);
|
||||
} else {
|
||||
// Add new
|
||||
setInventoryItems(items => [...items, formData]);
|
||||
}
|
||||
|
||||
setIsAdding(false);
|
||||
setEditingId(null);
|
||||
setFormData({
|
||||
id: '',
|
||||
name: '',
|
||||
product_type: ProductType.INGREDIENT,
|
||||
category: '',
|
||||
unit_of_measure: UnitOfMeasure.KILOGRAMS,
|
||||
isSuggested: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsAdding(false);
|
||||
setEditingId(null);
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
const handleAddTemplate = (template: IngredientTemplate) => {
|
||||
// Check for duplicates by name
|
||||
const existingNames = new Set(inventoryItems.map(item => item.name.toLowerCase()));
|
||||
|
||||
const newItems = template.items
|
||||
.filter(item => !existingNames.has(item.name.toLowerCase()))
|
||||
.map((item, index) => ({
|
||||
id: `template-${template.id}-${index}-${Date.now()}`,
|
||||
name: item.name,
|
||||
product_type: item.product_type,
|
||||
category: item.category,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
isSuggested: false,
|
||||
}));
|
||||
|
||||
if (newItems.length > 0) {
|
||||
setInventoryItems(items => [...items, ...newItems]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteStep = async () => {
|
||||
if (inventoryItems.length === 0) {
|
||||
setFormErrors({ submit: t('validation:min_items', 'Agrega al menos 1 producto para continuar') });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setFormErrors({});
|
||||
|
||||
try {
|
||||
// STEP 1: Create all inventory items in parallel
|
||||
// This MUST happen BEFORE sales import because sales records reference inventory IDs
|
||||
console.log('📦 Creating inventory items...', inventoryItems.length);
|
||||
console.log('📋 Items to create:', inventoryItems.map(item => ({
|
||||
name: item.name,
|
||||
product_type: item.product_type,
|
||||
category: item.category,
|
||||
unit_of_measure: item.unit_of_measure
|
||||
})));
|
||||
|
||||
const createPromises = inventoryItems.map((item, index) => {
|
||||
const ingredientData: IngredientCreate = {
|
||||
name: item.name,
|
||||
product_type: item.product_type,
|
||||
category: item.category,
|
||||
unit_of_measure: item.unit_of_measure as UnitOfMeasure,
|
||||
// All other fields are optional now!
|
||||
};
|
||||
|
||||
console.log(`🔄 Creating ingredient ${index + 1}/${inventoryItems.length}:`, ingredientData);
|
||||
|
||||
return createIngredientMutation.mutateAsync({
|
||||
tenantId,
|
||||
ingredientData,
|
||||
}).catch(error => {
|
||||
console.error(`❌ Failed to create ingredient "${item.name}":`, error);
|
||||
console.error('Failed ingredient data:', ingredientData);
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(createPromises);
|
||||
console.log('✅ Inventory items created successfully');
|
||||
|
||||
// STEP 2: Import sales data (only if file was uploaded)
|
||||
// Now that inventory exists, sales records can reference the inventory IDs
|
||||
let salesImported = false;
|
||||
if (initialData?.uploadedFile && tenantId) {
|
||||
try {
|
||||
console.log('📊 Importing sales data from file:', initialData.uploadedFileName);
|
||||
await importSalesMutation.mutateAsync({
|
||||
tenantId,
|
||||
file: initialData.uploadedFile,
|
||||
});
|
||||
salesImported = true;
|
||||
console.log('✅ Sales data imported successfully');
|
||||
} catch (salesError) {
|
||||
console.error('⚠️ Sales import failed (non-blocking):', salesError);
|
||||
// Don't block onboarding if sales import fails
|
||||
// Inventory is already created, which is the critical part
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the step with metadata
|
||||
onComplete({
|
||||
inventoryItemsCreated: inventoryItems.length,
|
||||
salesDataImported: salesImported,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating inventory items:', error);
|
||||
setFormErrors({ submit: t('error:creating_items', 'Error al crear los productos. Inténtalo de nuevo.') });
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Category options based on product type
|
||||
const getCategoryOptions = (productType: ProductType) => {
|
||||
if (productType === ProductType.INGREDIENT) {
|
||||
return Object.values(IngredientCategory).map(cat => ({
|
||||
value: cat,
|
||||
label: t(`inventory:enums.ingredient_category.${cat}`, cat)
|
||||
}));
|
||||
} else {
|
||||
return Object.values(ProductCategory).map(cat => ({
|
||||
value: cat,
|
||||
label: t(`inventory:enums.product_category.${cat}`, cat)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const unitOptions = Object.values(UnitOfMeasure).map(unit => ({
|
||||
value: unit,
|
||||
label: t(`inventory:enums.unit_of_measure.${unit}`, unit)
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-2">
|
||||
{t('onboarding:inventory_review.title', 'Revisar Inventario')}
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-[var(--text-secondary)]">
|
||||
{t('onboarding:inventory_review.description', 'Revisa y ajusta los productos detectados. Puedes editar, eliminar o agregar más productos.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Why This Matters */}
|
||||
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-info)]" />
|
||||
{t('setup_wizard:why_this_matters', '¿Por qué es importante?')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('onboarding:inventory_review.why', 'Estos productos serán la base de tu sistema. Diferenciamos entre Ingredientes (lo que usas para producir) y Productos Terminados (lo que vendes).')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Add Templates */}
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-900/10 dark:to-blue-900/10 border border-purple-200 dark:border-purple-700 rounded-lg p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||
{t('inventory:templates.title', 'Plantillas de Ingredientes')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{t('inventory:templates.description', 'Agrega ingredientes comunes con un solo clic. Solo se agregarán los que no tengas ya.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{INGREDIENT_TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => handleAddTemplate(template)}
|
||||
className="text-left p-4 bg-white dark:bg-gray-800 border-2 border-purple-200 dark:border-purple-700 rounded-lg hover:border-purple-400 dark:hover:border-purple-500 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-3xl group-hover:scale-110 transition-transform">{template.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{template.name}
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">
|
||||
{template.description}
|
||||
</p>
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">
|
||||
{template.items.length} {t('inventory:templates.items', 'ingredientes')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-2 border-b border-[var(--border-color)] overflow-x-auto scrollbar-hide -mx-2 px-2">
|
||||
<button
|
||||
onClick={() => setActiveFilter('all')}
|
||||
className={`px-3 md:px-4 py-3 font-medium transition-colors relative whitespace-nowrap text-sm md:text-base ${
|
||||
activeFilter === 'all'
|
||||
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{t('inventory:filter.all', 'Todos')} ({counts.all})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('finished_products')}
|
||||
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
|
||||
activeFilter === 'finished_products'
|
||||
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">{t('inventory:filter.finished_products', 'Productos Terminados')}</span>
|
||||
<span className="sm:hidden">{t('inventory:filter.finished_products_short', 'Productos')}</span> ({counts.finished_products})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('ingredients')}
|
||||
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
|
||||
activeFilter === 'ingredients'
|
||||
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Package className="w-5 h-5" />
|
||||
{t('inventory:filter.ingredients', 'Ingredientes')} ({counts.ingredients})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inventory List */}
|
||||
<div className="space-y-3">
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
{activeFilter === 'all'
|
||||
? t('inventory:empty_state', 'No hay productos. Agrega uno para comenzar.')
|
||||
: t('inventory:no_results', 'No hay productos de este tipo.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Item Card */}
|
||||
<div className="p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="font-medium text-[var(--text-primary)] truncate">{item.name}</h5>
|
||||
|
||||
{/* Product Type Badge */}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
item.product_type === ProductType.FINISHED_PRODUCT
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{item.product_type === ProductType.FINISHED_PRODUCT ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<ShoppingBag className="w-3 h-3" />
|
||||
{t('inventory:type.finished_product', 'Producto')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
{t('inventory:type.ingredient', 'Ingrediente')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* AI Suggested Badge */}
|
||||
{item.isSuggested && item.confidence_score && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-purple-100 text-purple-800">
|
||||
IA {Math.round(item.confidence_score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
|
||||
<span>{item.product_type === ProductType.INGREDIENT ? t(`inventory:enums.ingredient_category.${item.category}`, item.category) : t(`inventory:enums.product_category.${item.category}`, item.category)}</span>
|
||||
<span>•</span>
|
||||
<span>{t(`inventory:enums.unit_of_measure.${item.unit_of_measure}`, item.unit_of_measure)}</span>
|
||||
{item.sales_data && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{t('inventory:sales_avg', 'Ventas')}: {item.sales_data.average_daily_sales.toFixed(1)}/día</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-2 md:ml-4">
|
||||
<button
|
||||
onClick={() => handleEdit(item)}
|
||||
className="p-2 md:p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('common:edit', 'Editar')}
|
||||
aria-label={t('common:edit', 'Editar')}
|
||||
>
|
||||
<Edit2 className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-2 md:p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('common:delete', 'Eliminar')}
|
||||
aria-label={t('common:delete', 'Eliminar')}
|
||||
>
|
||||
<Trash2 className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Edit Form - appears right below the card being edited */}
|
||||
{editingId === item.id && (
|
||||
<div className="border-2 border-[var(--color-primary)] rounded-lg p-3 md:p-4 bg-[var(--bg-secondary)] ml-0 md:ml-4 mt-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-[var(--text-primary)]">
|
||||
{t('inventory:edit_item', 'Editar Producto')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Product Type Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('inventory:product_type', 'Tipo de Producto')} *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
|
||||
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
|
||||
formData.product_type === ProductType.INGREDIENT
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
|
||||
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{formData.product_type === ProductType.INGREDIENT && (
|
||||
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
|
||||
)}
|
||||
<Package className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
|
||||
<div className="font-medium text-[var(--text-primary)]">
|
||||
{t('inventory:type.ingredient', 'Ingrediente')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('inventory:type.ingredient_desc', 'Materias primas para producir')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
|
||||
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
|
||||
formData.product_type === ProductType.FINISHED_PRODUCT
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
|
||||
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{formData.product_type === ProductType.FINISHED_PRODUCT && (
|
||||
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
|
||||
)}
|
||||
<ShoppingBag className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
|
||||
<div className="font-medium text-[var(--text-primary)]">
|
||||
{t('inventory:type.finished_product', 'Producto Terminado')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('inventory:type.finished_product_desc', 'Productos que vendes')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('inventory:name', 'Nombre')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
placeholder={t('inventory:name_placeholder', 'Ej: Harina de trigo')}
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('inventory:category', 'Categoría')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
>
|
||||
<option value="">{t('common:select', 'Seleccionar...')}</option>
|
||||
{getCategoryOptions(formData.product_type).map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{formErrors.category && (
|
||||
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unit of Measure */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('inventory:unit_of_measure', 'Unidad de Medida')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.unit_of_measure}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, unit_of_measure: e.target.value as UnitOfMeasure }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
>
|
||||
{unitOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave}>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
{t('common:save', 'Guardar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Button - hidden when adding or editing */}
|
||||
{!isAdding && !editingId && (
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="w-full border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg p-4 md:p-4 hover:border-[var(--color-primary)]/50 transition-colors flex items-center justify-center gap-2 text-[var(--color-primary)] min-h-[44px] font-medium"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="text-sm md:text-base">{t('inventory:add_item', 'Agregar Producto')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add New Item Form - only shown when adding (not editing) */}
|
||||
{isAdding && !editingId && (
|
||||
<div className="border-2 border-[var(--color-primary)] rounded-lg p-3 md:p-4 bg-[var(--bg-secondary)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-[var(--text-primary)]">
|
||||
{t('inventory:add_item', 'Agregar Producto')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Product Type Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('inventory:product_type', 'Tipo de Producto')} *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
|
||||
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
|
||||
formData.product_type === ProductType.INGREDIENT
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
|
||||
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{formData.product_type === ProductType.INGREDIENT && (
|
||||
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
|
||||
)}
|
||||
<Package className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
|
||||
<div className="font-medium text-[var(--text-primary)]">
|
||||
{t('inventory:type.ingredient', 'Ingrediente')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('inventory:type.ingredient_desc', 'Materias primas para producir')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
|
||||
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
|
||||
formData.product_type === ProductType.FINISHED_PRODUCT
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
|
||||
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{formData.product_type === ProductType.FINISHED_PRODUCT && (
|
||||
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
|
||||
)}
|
||||
<ShoppingBag className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
|
||||
<div className="font-medium text-[var(--text-primary)]">
|
||||
{t('inventory:type.finished_product', 'Producto Terminado')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('inventory:type.finished_product_desc', 'Productos que vendes')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('inventory:name', 'Nombre')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
placeholder={t('inventory:name_placeholder', 'Ej: Harina de trigo')}
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('inventory:category', 'Categoría')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
>
|
||||
<option value="">{t('common:select', 'Seleccionar...')}</option>
|
||||
{getCategoryOptions(formData.product_type).map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{formErrors.category && (
|
||||
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unit of Measure */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('inventory:unit_of_measure', 'Unidad de Medida')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.unit_of_measure}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, unit_of_measure: e.target.value as UnitOfMeasure }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
>
|
||||
{unitOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave}>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
{t('common:add', 'Agregar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Error */}
|
||||
{formErrors.submit && (
|
||||
<div className="bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 rounded-lg p-4">
|
||||
<p className="text-sm text-[var(--color-danger)]">{formErrors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('inventory:summary', 'Resumen')}: {counts.finished_products} {t('inventory:finished_products', 'productos terminados')}, {counts.ingredients} {t('inventory:ingredients_count', 'ingredientes')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex flex-col-reverse sm:flex-row justify-between gap-3 sm:gap-4 pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isSubmitting || isFirstStep}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common:back', '← Atrás')}</span>
|
||||
<span className="sm:hidden">{t('common:back', 'Atrás')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCompleteStep}
|
||||
disabled={inventoryItems.length === 0 || isSubmitting}
|
||||
className="w-full sm:w-auto sm:min-w-[200px]"
|
||||
>
|
||||
{isSubmitting
|
||||
? t('common:saving', 'Guardando...')
|
||||
: <>
|
||||
<span className="hidden md:inline">{t('common:continue', 'Continuar')} ({inventoryItems.length} {t('common:items', 'productos')}) →</span>
|
||||
<span className="md:hidden">{t('common:continue', 'Continuar')} ({inventoryItems.length}) →</span>
|
||||
</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -157,13 +157,13 @@ export const ProductionProcessesStep: React.FC<ProductionProcessesStepProps> = (
|
||||
};
|
||||
|
||||
return (
|
||||
<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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user